Skip to content

Commit

Permalink
Added support for more detailed error logging (#204)
Browse files Browse the repository at this point in the history
Co-authored-by: Luke Towers <[email protected]>

Related: wintercms/winter#1303
  • Loading branch information
jaxwilko authored Feb 13, 2025
1 parent b9b5aae commit 2dc3501
Showing 1 changed file with 183 additions and 1 deletion.
184 changes: 183 additions & 1 deletion src/Foundation/Exception/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
use Illuminate\Support\Facades\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Winter\Storm\Exception\AjaxException;
use Winter\Storm\Support\Str;

class Handler extends ExceptionHandler
{
protected const EXCEPTION_LOG_VERSION = 2;
protected const EXCEPTION_SNIPPET_LINES = 12;

/**
* A list of the exception types that should not be reported.
*
Expand Down Expand Up @@ -65,7 +69,15 @@ public function report(Throwable $throwable)
}

if (class_exists('Log')) {
Log::error($throwable);
try {
// Attempt to log the exception with details
Log::error($throwable->getMessage(), $this->getDetails($throwable));
} catch (Throwable $e) {
// For some reason something failed, fall back to the old log style
Log::error($throwable);
// Log what failed in the v2 log style for debugging
Log::error($e->getMessage(), $this->getDetails($e));
}
}

/**
Expand Down Expand Up @@ -236,4 +248,174 @@ protected function hints(ReflectionFunction $reflection, $throwable)

return false;
}

/**
* Constructs the details array for logging
*
* @param Throwable $throwable
* @return array
*/
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
*
* @param Throwable $throwable
* @return array
*/
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
*
* @param array $trace
* @return array
* @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
*
* @param string $file
* @param int $line
* @return array
*/
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
*
* @return array
*/
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
*
* @param string $file
* @return bool
*/
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'));
}
}

0 comments on commit 2dc3501

Please sign in to comment.