├── README.markdown ├── Services ├── Hoptoad.php └── Hoptoad │ └── CodeIgniter.php ├── doc └── example.php └── test ├── hoptoad_2_0.xsd └── test.php /README.markdown: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is a simple [Hoptoad](http://hoptoadapp.com) notifier for PHP. It's been used in a few production sites now with success. It's not quite as fully featured as the official Ruby notifier but it works well. 4 | 5 | # Thanks 6 | 7 | Big thanks to Lou Kosak, Till Klampaeckel and Scott Woods who have contributed extensively to this project. 8 | 9 | # Limitations 10 | 11 | This notifier does not contain two big features from the Ruby notifier. The two are error filtering and deploy tracking. Error filtering will be coming in a future release. 12 | 13 | For deploy tracking, since I use Capistrano to deploy my PHP apps, I simply use the Ruby notifier to perform the deploy tracking. For this reason, unless someone wants to contribute patches, I don't see deploy tracking coming to the php notifier. 14 | 15 | # Requirements 16 | 17 | To use the default _pear_ client install Pear's HTTP_Request2: 18 | 19 | pear install HTTP_Request2 20 | 21 | To use the _curl_ client, install the PHP curl extension. To install on Ubuntu if you are using PHP 5 you would run: 22 | sudo apt-get install php5-curl 23 | sudo /etc/init.d/apache2 reload 24 | 25 | # BSD License 26 | 27 | Copyright (c) 2010, Rich Cavanaugh 28 | All rights reserved. 29 | 30 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 31 | 32 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 33 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 34 | * The name of the author may not be used to endorse or promote products derived from this software without specific prior written permission. 35 | 36 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 37 | -------------------------------------------------------------------------------- /Services/Hoptoad.php: -------------------------------------------------------------------------------- 1 | 8 | * @author Till Klampaeckel 9 | * @license 10 | * @version GIT: $Id$ 11 | * @link http://github.com/till/php-hoptoad-notifier 12 | */ 13 | 14 | 15 | /** 16 | * Services_Hoptoad 17 | * 18 | * @category error 19 | * @package Services_Hoptoad 20 | * @author Rich Cavanaugh 21 | * @author Till Klampaeckel 22 | * @license 23 | * @version Release: @package_version@ 24 | * @link http://github.com/rich/php-hoptoad-notifier 25 | */ 26 | class Services_Hoptoad 27 | { 28 | const NOTIFIER_NAME = 'php-hoptoad-notifier'; 29 | const NOTIFIER_VERSION = '0.2.0'; 30 | const NOTIFIER_URL = 'http://github.com/rich/php-hoptoad-notifier'; 31 | const NOTIFIER_API_VERSION = '2.0'; 32 | 33 | protected $error_class; 34 | protected $message; 35 | protected $file; 36 | protected $line; 37 | protected $trace; 38 | 39 | /** 40 | * Report E_STRICT 41 | * 42 | * @var bool $reportESTRICT 43 | * @todo Implement set! 44 | */ 45 | protected $reportESTRICT; 46 | 47 | /** 48 | * Timeout for cUrl. 49 | * @var int $timeout 50 | */ 51 | protected $timeout; 52 | 53 | public $client; // pear, curl or zend 54 | 55 | /** 56 | * @var mixed $apiKey 57 | */ 58 | public $apiKey; 59 | 60 | /** 61 | * @var string 62 | **/ 63 | public $environment; 64 | 65 | /** 66 | * Initialize the chosen notifier and install the error 67 | * and exception handlers that connect to Hoptoad 68 | * 69 | * @return void 70 | * @author Rich Cavanaugh 71 | */ 72 | public static function installHandlers($apiKey=NULL, $environment=NULL, $client=NULL, $class='Services_Hoptoad') 73 | { 74 | $hoptoad = new $class($apiKey, $environment, $client); 75 | $hoptoad->installNotifierHandlers(); 76 | } 77 | 78 | /** 79 | * Hook's this notifier to PHP error and exception handlers 80 | * @return void 81 | * @author Rich Cavanaugh 82 | **/ 83 | public function installNotifierHandlers() 84 | { 85 | set_error_handler(array($this, "errorHandler")); 86 | set_exception_handler(array($this, "exceptionHandler")); 87 | } 88 | 89 | /** 90 | * Initialize the Hoptad client 91 | * 92 | * @param string $apiKey 93 | * @param string $environment 94 | * @param string $client 95 | * @param string $reportESTRICT 96 | * @param int $timeout 97 | * @return void 98 | * @author Rich Cavanaugh 99 | */ 100 | function __construct($apiKey, $environment='production', $client='pear', $reportESTRICT=false, $timeout=2) 101 | { 102 | $this->apiKey = $apiKey; 103 | $this->environment = $environment; 104 | $this->client = $client; 105 | $this->reportESTRICT = $reportESTRICT; 106 | $this->timeout = $timeout; 107 | $this->setup(); 108 | } 109 | 110 | /** 111 | * A method meant specifically for subclasses to override so they don't need 112 | * to handle the constructor 113 | * @return void 114 | * @author Rich Cavanaugh 115 | **/ 116 | public function setup() 117 | { 118 | // we don't do anything here in the base class 119 | } 120 | 121 | /** 122 | * Handle a php error 123 | * 124 | * @param string $code 125 | * @param string $message 126 | * @param string $file 127 | * @param string $line 128 | * @return void 129 | * @author Rich Cavanaugh 130 | */ 131 | public function errorHandler($code, $message, $file, $line) 132 | { 133 | if ($code == E_STRICT && $this->reportESTRICT === false) return; 134 | 135 | $this->notify($code, $message, $file, $line, debug_backtrace()); 136 | } 137 | 138 | /** 139 | * Handle a raised exception 140 | * 141 | * @param Exception $exception 142 | * @return void 143 | * @author Rich Cavanaugh 144 | */ 145 | public function exceptionHandler($exception) 146 | { 147 | $this->notify(get_class($exception), $exception->getMessage(), $exception->getFile(), $exception->getLine(), $exception->getTrace()); 148 | } 149 | 150 | /** 151 | * Set the values to be used for the next notice sent to Hoptoad 152 | * @return void 153 | * @author Rich Cavanaugh 154 | **/ 155 | public function setParamsForNotify($error_class, $message, $file, $line, $trace, $component=NULL) 156 | { 157 | $this->error_class = $error_class; 158 | $this->message = $message; 159 | $this->file = $file; 160 | $this->line = $line; 161 | $this->trace = $trace; 162 | $this->component = $component; 163 | } 164 | 165 | /** 166 | * Pass the error and environment data on to Hoptoad 167 | * 168 | * @param mixed $error_class 169 | * @param string $message 170 | * @param string $file 171 | * @param string $line 172 | * @param array $trace 173 | * @param string $environment 174 | * 175 | * @author Rich Cavanaugh 176 | * @todo Handle response (e.g. errors) 177 | */ 178 | function notify($error_class, $message, $file, $line, $trace, $component=NULL) 179 | { 180 | $this->setParamsForNotify($error_class, $message, $file, $line, $trace, $component); 181 | 182 | $url = "http://hoptoadapp.com/notifier_api/v2/notices"; 183 | $headers = array( 184 | 'Accept' => 'text/xml, application/xml', 185 | 'Content-Type' => 'text/xml' 186 | ); 187 | $body = $this->buildXmlNotice(); 188 | 189 | try { 190 | $status = call_user_func_array(array($this, $this->client . 'Request'), array($url, $headers, $body)); 191 | if ($status != 200) $this->handleErrorResponse($status); 192 | } catch (RuntimeException $e) { 193 | // TODO do something reasonable with the runtime exception. 194 | // we can't really throw our runtime exception since we're likely in 195 | // an exception handler. Punt on this for now and come back to it. 196 | } 197 | } 198 | 199 | /** 200 | * Build up the XML to post according to the documentation at: 201 | * http://help.hoptoadapp.com/faqs/api-2/notifier-api-v2 202 | * @return string 203 | * @author Rich Cavanaugh 204 | **/ 205 | function buildXmlNotice() 206 | { 207 | $doc = new SimpleXMLElement(''); 208 | $doc->addAttribute('version', self::NOTIFIER_API_VERSION); 209 | $doc->addChild('api-key', $this->apiKey); 210 | 211 | $notifier = $doc->addChild('notifier'); 212 | $notifier->addChild('name', self::NOTIFIER_NAME); 213 | $notifier->addChild('version', self::NOTIFIER_VERSION); 214 | $notifier->addChild('url', self::NOTIFIER_URL); 215 | 216 | $error = $doc->addChild('error'); 217 | $error->addChild('class', $this->error_class); 218 | $error->addChild('message', $this->message); 219 | $this->addXmlBacktrace($error); 220 | 221 | $request = $doc->addChild('request'); 222 | $request->addChild('url', htmlspecialchars($this->request_uri())); 223 | $request->addChild('component', $this->component()); 224 | $request->addChild('action', $this->action()); 225 | 226 | if (isset($_REQUEST)) $this->addXmlVars($request, 'params', $this->params()); 227 | if (isset($_SESSION)) $this->addXmlVars($request, 'session', $this->session()); 228 | if (isset($_SERVER)) $this->addXmlVars($request, 'cgi-data', $this->cgi_data()); 229 | 230 | $env = $doc->addChild('server-environment'); 231 | $env->addChild('project-root', $this->project_root()); 232 | $env->addChild('environment-name', $this->environment()); 233 | 234 | return $doc->asXML(); 235 | } 236 | 237 | /** 238 | * Add a Hoptoad var block to the XML 239 | * @return void 240 | * @author Rich Cavanaugh 241 | **/ 242 | function addXmlVars($parent, $key, $source) 243 | { 244 | if (empty($source)) return; 245 | 246 | $node = $parent->addChild($key); 247 | foreach ($source as $key => $val) { 248 | $var_node = $node->addChild('var', htmlspecialchars(var_export($val, true))); 249 | $var_node->addAttribute('key', $key); 250 | } 251 | } 252 | 253 | /** 254 | * Add a Hoptoad backtrace to the XML 255 | * @return void 256 | * @author Rich Cavanaugh 257 | **/ 258 | function addXmlBacktrace($parent) 259 | { 260 | $backtrace = $parent->addChild('backtrace'); 261 | $line_node = $backtrace->addChild('line'); 262 | $line_node->addAttribute('file', $this->file); 263 | $line_node->addAttribute('number', $this->line); 264 | 265 | foreach ($this->trace as $entry) { 266 | if (isset($entry['class']) && $entry['class'] == 'Services_Hoptoad') continue; 267 | 268 | $line_node = $backtrace->addChild('line'); 269 | $line_node->addAttribute('file', $entry['file']); 270 | $line_node->addAttribute('number', $entry['line']); 271 | $line_node->addAttribute('method', $entry['function']); 272 | } 273 | } 274 | 275 | /** 276 | * params 277 | * @return array 278 | * @author Scott Woods 279 | **/ 280 | function params() { 281 | return $_REQUEST; 282 | } 283 | 284 | /** 285 | * session 286 | * @return array 287 | * @author Scott Woods 288 | **/ 289 | function session() { 290 | return $_SESSION; 291 | } 292 | 293 | /** 294 | * cgi_data 295 | * @return array 296 | * @author Scott Woods 297 | **/ 298 | function cgi_data() { 299 | if (isset($_ENV) && !empty($_ENV)) { 300 | return array_merge($_SERVER, $_ENV); 301 | } 302 | return $_SERVER; 303 | } 304 | 305 | /** 306 | * component 307 | * @return mixed 308 | * @author Scott Woods 309 | **/ 310 | function component() { 311 | return $this->component; 312 | } 313 | 314 | /** 315 | * action 316 | * @return mixed 317 | * @author Scott Woods 318 | **/ 319 | function action() { 320 | return ''; 321 | } 322 | 323 | /** 324 | * environment 325 | * @return string 326 | * @author Rich Cavanaugh 327 | **/ 328 | function environment() { 329 | return $this->environment; 330 | } 331 | 332 | /** 333 | * project_root 334 | * @return string 335 | * @author Scott Woods 336 | **/ 337 | function project_root() { 338 | if (isset($_SERVER['DOCUMENT_ROOT'])) { 339 | return $_SERVER['DOCUMENT_ROOT']; 340 | } else { 341 | return dirname(__FILE__); 342 | } 343 | } 344 | 345 | 346 | /** 347 | * get the request uri 348 | * @return string 349 | * @author Scott Woods 350 | **/ 351 | function request_uri() { 352 | if (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) { 353 | $protocol = 'https'; 354 | } else { 355 | $protocol = 'http'; 356 | } 357 | $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''; 358 | $path = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; 359 | $query_string = isset($_SERVER['QUERY_STRING']) && !empty($_SERVER['QUERY_STRING']) ? ('?' . $_SERVER['QUERY_STRING']) : ''; 360 | return "{$protocol}://{$host}{$path}{$query_string}"; 361 | } 362 | 363 | /** 364 | * @param mixed $code The HTTP status code from Hoptoad. 365 | * 366 | * @return void 367 | * @throws RuntimeException Error message from hoptoad, translated to a RuntimeException. 368 | */ 369 | protected function handleErrorResponse($code) 370 | { 371 | switch ($code) { 372 | case '403': 373 | $msg = 'The requested project does not support SSL - resubmit in an http request.'; 374 | break; 375 | case '422': 376 | $msg = 'The submitted notice was invalid - check the notice xml against the schema.'; 377 | break; 378 | case '500': 379 | $msg = 'Unexpected errors - submit a bug report at http://help.hoptoadapp.com.'; 380 | break; 381 | default: 382 | $msg = 'Unknown error code from Hoptoad\'s API: ' . $code; 383 | break; 384 | } 385 | 386 | throw new RuntimeException($msg, $code); 387 | } 388 | 389 | /** 390 | * Send the request to Hoptoad using PEAR 391 | * @return integer 392 | * @author Rich Cavanaugh 393 | **/ 394 | public function pearRequest($url, $headers, $body) 395 | { 396 | if (!class_exists('HTTP_Request2')) require_once('HTTP/Request2.php'); 397 | if (!class_exists('HTTP_Request2_Adapter_Socket')) require_once 'HTTP/Request2/Adapter/Socket.php'; 398 | 399 | $adapter = new HTTP_Request2_Adapter_Socket; 400 | $req = new HTTP_Request2($url, HTTP_Request2::METHOD_POST); 401 | $req->setAdapter($adapter); 402 | $req->setHeader($headers); 403 | $req->setBody($body); 404 | return $req->send()->getStatus(); 405 | } 406 | 407 | /** 408 | * Send the request to Hoptoad using Curl 409 | * @return integer 410 | * @author Rich Cavanaugh 411 | **/ 412 | public function curlRequest($url, $headers, $body) 413 | { 414 | $header_strings = array(); 415 | foreach ($headers as $key => $val) { 416 | $header_strings[] = "{$key}: {$val}"; 417 | } 418 | 419 | $curlHandle = curl_init(); 420 | curl_setopt($curlHandle, CURLOPT_URL, $url); 421 | curl_setopt($curlHandle, CURLOPT_POST, 1); 422 | curl_setopt($curlHandle, CURLOPT_HEADER, 0); 423 | curl_setopt($curlHandle, CURLOPT_TIMEOUT, $this->timeout); 424 | curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $body); 425 | curl_setopt($curlHandle, CURLOPT_HTTPHEADER, $header_strings); 426 | curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, 1); 427 | curl_exec($curlHandle); 428 | $status = curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); 429 | curl_close($curlHandle); 430 | return $status; 431 | } 432 | 433 | /** 434 | * Send the request to Hoptoad using Zend 435 | * @return integer 436 | * @author Rich Cavanaugh 437 | **/ 438 | public function zendRequest($url, $headers, $body) 439 | { 440 | $header_strings = array(); 441 | foreach ($headers as $key => $val) { 442 | $header_strings[] = "{$key}: {$val}"; 443 | } 444 | 445 | $client = new Zend_Http_Client($url); 446 | $client->setHeaders($header_strings); 447 | $client->setRawData($body, 'text/xml'); 448 | 449 | $response = $client->request('POST'); 450 | 451 | return $response->getStatus(); 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /Services/Hoptoad/CodeIgniter.php: -------------------------------------------------------------------------------- 1 | ci =& get_instance(); 7 | } 8 | 9 | function action() { 10 | return $this->ci->router->method; 11 | } 12 | 13 | function component() { 14 | return $this->ci->router->class; 15 | } 16 | 17 | function project_path() { 18 | return APPPATH; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /doc/example.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 20 | 21 | 30 | 31 | 41 | 42 | -------------------------------------------------------------------------------- /test/hoptoad_2_0.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /test/test.php: -------------------------------------------------------------------------------- 1 | 2 | 'localhost', 8 | 'REQUEST_URI' => '/example.php', 9 | 'HTTP_REFERER' => 'http://localhost/reports/something', 10 | 'QUERY_STRING' => 'arg1=val1&arg2=val2' 11 | ); 12 | 13 | $_SESSION = array( 14 | 'var1' => 'val1', 15 | 'var2' => 'val2', 16 | ); 17 | 18 | $_GET = array( 19 | 'get1' => 'val1', 20 | 'get2' => 'val2', 21 | ); 22 | 23 | $_POST = array( 24 | 'post1' => 'val3', 25 | 'post2' => 'val4', 26 | ); 27 | 28 | $_REQUEST = array_merge($_GET, $_POST); 29 | 30 | class HoptoadTest extends PHPUnit_Framework_TestCase 31 | { 32 | protected function setUp() 33 | { 34 | $this->hoptoad = new Services_Hoptoad('myAPIKey', 'production', 'pear', false, 2); 35 | 36 | $trace = array( 37 | array( 38 | 'class' => 'Hoptoad', 39 | 'file' => 'file.php', 40 | 'line' => 23, 41 | 'function' => 'foo', 42 | ), 43 | array( 44 | 'class' => 'Foo', 45 | 'file' => 'foo.php', 46 | 'line' => 242, 47 | 'function' => 'foo', 48 | ), 49 | array( 50 | 'class' => 'Bar', 51 | 'file' => 'bar.php', 52 | 'line' => 42, 53 | 'function' => 'bar', 54 | ), 55 | ); 56 | $this->hoptoad->setParamsForNotify('ERROR', 'Something went wrong', 'foo', 23, $trace); 57 | } 58 | 59 | public function testRequestURI() 60 | { 61 | // check protocol support 62 | $this->assertEquals('http://localhost/example.php?arg1=val1&arg2=val2', $this->hoptoad->request_uri()); 63 | $_SERVER['SERVER_PORT'] = 443; 64 | $this->assertEquals('https://localhost/example.php?arg1=val1&arg2=val2', $this->hoptoad->request_uri()); 65 | $_SERVER['SERVER_PORT'] = 80; 66 | 67 | // Check without query string. 68 | $old_query_string = $_SERVER['QUERY_STRING']; 69 | $_SERVER['QUERY_STRING'] = ''; 70 | $this->assertEquals('http://localhost/example.php', $this->hoptoad->request_uri()); 71 | $_SERVER['QUERY_STRING'] = $old_query_string; 72 | } 73 | 74 | public function testXMLBacktrace() 75 | { 76 | $expected_xml = << 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | XML; 86 | $doc = new SimpleXMLElement(''); 87 | $this->hoptoad->addXmlBacktrace($doc); 88 | $this->assertXmlStringEqualsXmlString($expected_xml, $doc->asXML()); 89 | } 90 | 91 | public function testXMLParams() 92 | { 93 | $expected_xml = << 95 | 96 | val1 97 | val2 98 | val3 99 | val4 100 | 101 | 102 | XML; 103 | $doc = new SimpleXMLElement(''); 104 | $this->hoptoad->addXmlVars($doc, 'params', $_REQUEST); 105 | $this->assertXmlStringEqualsXmlString($expected_xml, $doc->asXML()); 106 | } 107 | 108 | public function testNotificationBody() 109 | { 110 | $xmllint = popen('xmllint --noout --schema test/hoptoad_2_0.xsd - 2> /dev/null', 'w'); 111 | if ($xmllint) { 112 | fwrite($xmllint, $this->hoptoad->buildXmlNotice()); 113 | $status = pclose($xmllint); 114 | $this->assertEquals(0, $status, "XML output did not validate against schema."); 115 | } else { 116 | $this->fail("Couldn't run xmllint command."); 117 | } 118 | } 119 | 120 | } 121 | ?> 122 | 123 | --------------------------------------------------------------------------------