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