diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php index 3a8b58f30..774694d0e 100644 --- a/modules/system/ServiceProvider.php +++ b/modules/system/ServiceProvider.php @@ -356,9 +356,23 @@ protected function registerErrorHandler() protected function registerLogging() { Event::listen(\Illuminate\Log\Events\MessageLogged::class, function ($event) { + if (!EventLog::useLogging()) { + return; + } + + $details = $event->context ?? null; + + // This allows for preventing db logging in cases where we don't want all log messages logged to the DB. + if (isset($details['skipDatabaseLog']) && $details['skipDatabaseLog']) { + return; + } + + EventLog::add($event->message, $event->level, $details); + }); + + Event::listen('exception.report', function (\Throwable $throwable) { if (EventLog::useLogging()) { - $details = $event->context ?? null; - EventLog::add($event->message, $event->level, $details); + EventLog::addException($throwable); } }); } diff --git a/modules/system/models/EventLog.php b/modules/system/models/EventLog.php index 2d2efc358..027f19e56 100644 --- a/modules/system/models/EventLog.php +++ b/modules/system/models/EventLog.php @@ -1,9 +1,11 @@ message = $message; @@ -59,6 +64,25 @@ public static function add(string $message, string $level = 'info', ?array $deta return $record; } + /** + * Creates an exception log record + */ + public static function addException(Throwable $throwable, string $level = 'error'): static + { + $record = new static; + $record->message = $throwable->getMessage(); + $record->level = $level; + $record->details = $record->getDetails($throwable); + + try { + $record->save(); + } + catch (Exception $ex) { + } + + return $record; + } + /** * Beautify level value. */ @@ -82,4 +106,157 @@ public function getSummaryAttribute(): string return Str::limit($matches[1] ?? '', 500); } + + /** + * Constructs the details array for logging + */ + public function getDetails(Throwable $throwable): array + { + return [ + 'logVersion' => static::EXCEPTION_LOG_VERSION, + 'exception' => $this->exceptionToArray($throwable), + 'environment' => $this->getEnviromentInfo(), + ]; + } + + /** + * Convert a throwable into an array of data for logging + */ + protected function exceptionToArray(Throwable $throwable): array + { + return [ + 'type' => $throwable::class, + 'message' => $throwable->getMessage(), + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'snippet' => $this->getSnippet($throwable->getFile(), $throwable->getLine()), + 'trace' => $this->exceptionTraceToArray($throwable->getTrace()), + 'stringTrace' => $throwable->getTraceAsString(), + 'code' => $throwable->getCode(), + 'previous' => $throwable->getPrevious() + ? $this->exceptionToArray($throwable->getPrevious()) + : null, + ]; + } + + /** + * Generate an array trace with extra data not provided by the default trace + * + * @throws \ReflectionException + */ + protected function exceptionTraceToArray(array $trace): array + { + foreach ($trace as $index => $frame) { + if (!isset($frame['file']) && isset($frame['class'])) { + $ref = new ReflectionClass($frame['class']); + $frame['file'] = $ref->getFileName(); + + if (!isset($frame['line']) && isset($frame['function']) && !str_contains($frame['function'], '{')) { + foreach (file($frame['file']) as $line => $text) { + if (preg_match(sprintf('/function\s.*%s/', $frame['function']), $text)) { + $frame['line'] = $line + 1; + break; + } + } + } + } + + $trace[$index] = [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + 'function' => $frame['function'] ?? null, + 'class' => $frame['class'] ?? null, + 'type' => $frame['type'] ?? null, + 'snippet' => !empty($frame['file']) && !empty($frame['line']) + ? $this->getSnippet($frame['file'], $frame['line']) + : '', + 'in_app' => ($frame['file'] ?? null) ? $this->isInAppError($frame['file']) : false, + 'arguments' => array_map(function ($arg) { + if (is_numeric($arg)) { + return $arg; + } + if (is_string($arg)) { + return "'$arg'"; + } + if (is_null($arg)) { + return 'null'; + } + if (is_bool($arg)) { + return $arg ? 'true' : 'false'; + } + if (is_array($arg)) { + return 'Array'; + } + if (is_object($arg)) { + return get_class($arg); + } + if (is_resource($arg)) { + return 'Resource'; + } + }, $frame['args'] ?? []), + ]; + } + + return $trace; + } + + /** + * Get the code snippet referenced in a trace + */ + protected function getSnippet(string $file, int $line): array + { + if (str_contains($file, ': eval()\'d code')) { + return []; + } + + $lines = file($file); + + if (count($lines) < static::EXCEPTION_SNIPPET_LINES) { + return $lines; + } + + return array_slice( + $lines, + $line - (static::EXCEPTION_SNIPPET_LINES / 2), + static::EXCEPTION_SNIPPET_LINES, + true + ); + } + + /** + * Get environment details to record with the exception + */ + protected function getEnviromentInfo(): array + { + if (app()->runningInConsole()) { + return [ + 'context' => 'CLI', + 'testing' => app()->runningUnitTests(), + 'env' => app()->environment(), + ]; + } + + return [ + 'context' => 'Web', + 'backend' => method_exists(app(), 'runningInBackend') ? app()->runningInBackend() : false, + 'testing' => app()->runningUnitTests(), + 'url' => app('url')->current(), + 'method' => app('request')->method(), + 'env' => app()->environment(), + 'ip' => app('request')->ip(), + 'userAgent' => app('request')->header('User-Agent'), + ]; + } + + /** + * Helper to work out if a file should be considered "In App" or not + */ + protected function isInAppError(string $file): bool + { + if (basename($file) === 'index.php' || basename($file) === 'artisan') { + return false; + } + + return !Str::startsWith($file, base_path('vendor')) && !Str::startsWith($file, base_path('modules')); + } }