1<?php 2 3namespace dokuwiki\plugin\sentry; 4 5/** 6 * A Sentry Event 7 */ 8class Event 9{ 10 const CLIENT = 'DokuWiki-SentryPlugin'; 11 const VERSION = 1; 12 13 // the Sentry log levels 14 const LVL_DEBUG = 'debug'; 15 const LVL_INFO = 'info'; 16 const LVL_WARN = 'warning'; 17 const LVL_ERROR = 'error'; 18 const LVL_FATAL = 'fatal'; 19 20 // core error types mapped to severity and name 21 const CORE_ERRORS = [ 22 E_ERROR => [self::LVL_ERROR, 'E_ERROR'], 23 E_WARNING => [self::LVL_WARN, 'E_WARNING'], 24 E_PARSE => [self::LVL_ERROR, 'E_PARSE'], 25 E_NOTICE => [self::LVL_INFO, 'E_NOTICE'], 26 E_CORE_ERROR => [self::LVL_ERROR, 'E_CORE_ERROR'], 27 E_CORE_WARNING => [self::LVL_WARN, 'E_CORE_WARNING'], 28 E_COMPILE_ERROR => [self::LVL_ERROR, 'E_COMPILE_ERROR'], 29 E_COMPILE_WARNING => [self::LVL_WARN, 'E_COMPILE_WARNING'], 30 E_USER_ERROR => [self::LVL_ERROR, 'E_USER_ERROR'], 31 E_USER_WARNING => [self::LVL_WARN, 'E_USER_WARNING'], 32 E_USER_NOTICE => [self::LVL_INFO, 'E_USER_NOTICE'], 33 E_STRICT => [self::LVL_INFO, 'E_STRICT'], 34 E_RECOVERABLE_ERROR => [self::LVL_ERROR, 'E_RECOVERABLE_ERROR'], 35 E_DEPRECATED => [self::LVL_WARN, 'E_DEPRECATED'], 36 E_USER_DEPRECATED => [self::LVL_WARN, 'E_USER_DEPRECATED'], 37 ]; 38 39 protected $data = []; 40 41 /** 42 * Initialize a new event with all default data 43 * 44 * @param null|array $data optional merge this data 45 */ 46 public function __construct($data = null) 47 { 48 $this->data = []; 49 $this->data['event_id'] = md5(random_bytes(512)); 50 $this->data['timestamp'] = gmdate('Y-m-d\TH:i:s'); 51 $this->data['logger'] = 'default'; 52 $this->data['level'] = self::LVL_ERROR; 53 $this->data['platform'] = 'php'; 54 $this->data['server_name'] = $_SERVER['SERVER_NAME']; 55 $this->data['sdk'] = [ 56 'name' => self::CLIENT, 57 'version' => self::VERSION, 58 ]; 59 $this->data['release'] = getVersion(); 60 61 /** @var \helper_plugin_sentry $helper */ 62 $helper = plugin_load('helper', 'sentry'); 63 $env = $helper->getConf('env'); 64 if ($env) { 65 $this->data['environment'] = $env; 66 } 67 68 $this->data['contexts'] = []; 69 $this->initUserContext(); 70 $this->initHttpContext(); 71 $this->initAppContext(); 72 $this->initRuntimeContext(); 73 $this->initBrowserContext(); 74 $this->initOsContext(); 75 $this->initModules(); 76 77 if (is_array($data)) { 78 $this->data = array_merge($this->data, $data); 79 } 80 } 81 82 /** 83 * Get the ID of this event 84 * 85 * @return string 86 */ 87 public function getID() 88 { 89 return $this->data['event_id']; 90 } 91 92 /** 93 * @param string $level one of the LVL_* constants 94 */ 95 public function setLogLevel($level) 96 { 97 $this->data['level'] = $level; 98 } 99 100 /** 101 * Add an exception as cause of this event 102 * 103 * Recurses into previous exceptions 104 * 105 * @param \Throwable|\Exception $e 106 */ 107 public function addException($e) 108 { 109 if (!is_array($this->data['exception'])) { 110 $this->data['exception'] = ['values' => []]; 111 } 112 113 // ErrorExceptions have a level 114 // we set it first, so older Exception overwrite newer ones when they are nested 115 if (method_exists($e, 'getSeverity')) { 116 $this->setLogLevel($this->errorTypeToSeverity($e->getSeverity())); 117 } else { 118 $this->setLogLevel(self::LVL_ERROR); 119 } 120 121 // log previous exception first 122 if ($e->getPrevious() !== null) { 123 $this->addException($e->getPrevious()); 124 } 125 126 // add exception 127 $this->data['exception']['values'][] = [ 128 'type' => get_class($e), 129 'value' => $e->getMessage(), 130 'stacktrace' => ['frames' => self::backTraceFrames($e->getTrace())], 131 ]; 132 133 // extract extras 134 $this->extractExceptionExtras($e); 135 } 136 137 /** 138 * Extracts all public properties of an exception into the extra array 139 * 140 * @param \Throwable $e 141 */ 142 protected function extractExceptionExtras(\Throwable $e) 143 { 144 $props = get_object_vars($e); 145 if (!is_array($props)) return; 146 if (isset($props['xdebug_message'])) unset($props['xdebug_message']); // nothing interesting in there 147 if (!isset($this->data['extra'])) $this->data['extra'] = []; 148 $this->data['extra'] = array_merge($this->data['extra'], $props); 149 } 150 151 /** 152 * Set an error as the cause of this event 153 * 154 * @param array $error 155 */ 156 protected function setError($error) 157 { 158 // a stack trace is optional 159 if (isset($error['trace'])) { 160 $trace = $error['trace']; 161 } else { 162 $trace = [$error]; 163 } 164 $trace = self::backTraceFrames($trace); 165 166 // create the exception entry 167 $this->data['exception'] = [ 168 'values' => [ 169 [ 170 'type' => $this->errorTypeToString($error['type']), 171 'value' => $error['message'], 172 'stacktrace' => [ 173 'frames' => $trace 174 ], 175 ], 176 ], 177 ]; 178 $this->setLogLevel($this->errorTypeToSeverity($error['type'])); 179 } 180 181 /** 182 * @return string 183 */ 184 public function getJSON() 185 { 186 return json_encode($this->data); 187 } 188 189 // region context initializers 190 191 /** 192 * Initialize the User Context 193 */ 194 protected function initUserContext() 195 { 196 global $USERINFO; 197 198 $this->data['user'] = ['ip_address' => $_SERVER['REMOTE_ADDR']]; 199 if (isset($_SERVER['REMOTE_USER'])) { 200 $this->data['user']['username'] = $_SERVER['REMOTE_USER']; 201 } 202 if (isset($USERINFO['mail'])) { 203 $this->data['user']['email'] = $USERINFO['mail']; 204 } 205 } 206 207 /** 208 * Initialize the HTTP Context 209 * 210 * @fixme this currently does not cover all envionments 211 */ 212 protected function initHttpContext() 213 { 214 $url = is_ssl() ? 'https://' : 'http://'; 215 $url .= $_SERVER['HTTP_HOST']; 216 $url .= $_SERVER['REQUEST_URI']; 217 218 $this->data['request'] = [ 219 'url' => $url, 220 'method' => $_SERVER['REQUEST_METHOD'], 221 'cookies' => $_SERVER['HTTP_COOKIE'], 222 'query_string' => $_SERVER['QUERY_STRING'], 223 ]; 224 225 if (function_exists('apache_request_headers')) { 226 $this->data['request']['headers'] = apache_request_headers(); 227 } 228 229 $this->data['request']['env'] = [ 230 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 231 ]; 232 } 233 234 /** 235 * Initialize App (DokuWiki) Context 236 */ 237 protected function initAppContext() 238 { 239 $this->data['contexts']['app'] = [ 240 'app_name' => 'DokuWiki', 241 'app_version' => getVersion() 242 ]; 243 } 244 245 /** 246 * Initialize Runtime (PHP) Context 247 */ 248 protected function initRuntimeContext() 249 { 250 $this->data['contexts']['runtime'] = [ 251 'name' => 'PHP', 252 'version' => PHP_VERSION, 253 'os' => PHP_OS, 254 'sapi' => PHP_SAPI 255 ]; 256 if (isset($_SERVER['SERVER_SOFTWARE'])) { 257 $this->data['contexts']['runtime']['server'] = $_SERVER['SERVER_SOFTWARE']; 258 } 259 } 260 261 /** 262 * Initialize Browser Context 263 */ 264 protected function initBrowserContext() 265 { 266 $browser = new Browser(); 267 $this->data['contexts']['browser'] = [ 268 'ua' => $_SERVER['HTTP_USER_AGENT'], 269 'name' => $browser->getBrowser(), 270 'version' => $browser->getVersion(), 271 ]; 272 } 273 274 /** 275 * Initialize OS Context 276 */ 277 protected function initOsContext() 278 { 279 $browser = new Browser(); 280 $this->data['contexts']['os'] = [ 281 'name' => $browser->getPlatform(), 282 ]; 283 } 284 285 /** 286 * Adds the enabled plugins and the current template to the modules section 287 */ 288 protected function initModules() 289 { 290 $this->data['modules'] = []; 291 $this->addPluginsToModules(); 292 $this->addTemplateToModules(); 293 } 294 295 /** 296 * Writes the enabled plugins and their version to the modules section 297 * 298 * If a plugin.info.txt can not be read, than an error message is recorded instead of the version 299 * 300 * see https://docs.sentry.io/clientdev/attributes/#optional-attributes 301 */ 302 protected function addPluginsToModules() 303 { 304 /* @var \Doku_Plugin_Controller $plugin_controller */ 305 global $plugin_controller; 306 $pluginlist = $plugin_controller->getList('', false); 307 foreach ($pluginlist as $pluginName) { 308 $infopath = DOKU_PLUGIN . $pluginName . '/plugin.info.txt'; 309 if (is_readable($infopath)) { 310 $pluginInfo = confToHash($infopath); 311 $this->data['modules']['plugin.' . $pluginName] = $pluginInfo['date']; 312 } else { 313 $this->data['modules']['plugin.' . $pluginName] = 'plugin.info.txt unreadable'; 314 } 315 } 316 } 317 318 /** 319 * Writes the current template and its version to the modules section 320 * 321 * If a template.info.txt can not be read, than an error message is recorded instead of the version 322 * 323 * see https://docs.sentry.io/clientdev/attributes/#optional-attributes 324 */ 325 protected function addTemplateToModules() 326 { 327 global $conf; 328 $tplpath = DOKU_TPLINC . 'template.info.txt'; 329 if (is_readable($tplpath)) { 330 $templateInfo = confToHash($tplpath); 331 $this->data['modules']['template.' . $conf['template']] = $templateInfo['date']; 332 } else { 333 $this->data['modules']['template.' . $conf['template']] = 'template.info.txt unreadable'; 334 } 335 } 336 337 // endregion 338 339 /** 340 * Translate a PHP Error constant into a Sentry log level group 341 * 342 * @param int $type PHP E_$x error constant 343 * @return string Sentry log level group 344 */ 345 protected function errorTypeToSeverity($type) 346 { 347 if (!empty(self::CORE_ERRORS[$type])) return self::CORE_ERRORS[$type][0]; 348 return self::LVL_ERROR; 349 } 350 351 /** 352 * Get the PHP Error constant as string for logging purposes 353 * 354 * @param int $type PHP E_$x error constant 355 * @return string E_$x error constant as string 356 */ 357 protected function errorTypeToString($type) 358 { 359 if (!empty(self::CORE_ERRORS[$type])) return self::CORE_ERRORS[$type][1]; 360 return 'E_UNKNOWN_ERROR_TYPE'; 361 } 362 363 /** 364 * Convert a PHP backtrace to Sentry stacktrace frames 365 * 366 * @param array $trace 367 * @return array 368 */ 369 public static function backTraceFrames($trace) 370 { 371 $frames = []; 372 foreach (array_reverse($trace) as $frame) { 373 $frames[] = [ 374 'filename' => $frame['file'], 375 'lineno' => $frame['line'], 376 'function' => isset($frame['function']) ? $frame['function'] : '', 377 'vars' => isset($frame['args']) ? $frame['args'] : [], 378 ]; 379 } 380 return $frames; 381 } 382 383 // region factory methods 384 385 /** 386 * Load an event from JSON encoded data 387 * 388 * @param string $json 389 * @return Event 390 */ 391 static public function fromJSON($json) 392 { 393 return new Event(json_decode($json, true)); 394 } 395 396 /** 397 * Generate an event from a exception 398 * 399 * @param \Throwable|\Exception $e 400 * @return Event 401 */ 402 static public function fromException($e) 403 { 404 $ev = new Event(); 405 $ev->addException($e); 406 return $ev; 407 } 408 409 /** 410 * Generate an event from an error 411 * 412 * Errors can be obtained via error_get_last() 413 * 414 * @param array $error 415 * @return Event 416 */ 417 public static function fromError($error) 418 { 419 $ev = new Event(); 420 $ev->setError($error); 421 return $ev; 422 } 423 424 // endregion 425 426} 427