diff --git a/README.md b/README.md index 4cb389e..73bfd8c 100644 --- a/README.md +++ b/README.md @@ -156,3 +156,26 @@ class SomeDomain { ) {} } ``` + +## Using DataDog processor + +This processor is meant to be used with the DataDog agent to send the stack trace to DataDog. + +And it also supports excluding frames from the stack trace. +This is useful if you have a lot of duplicate frames in the stack trace, and you don't want to send them to DataDog. +It's also nice to remove framework code from the stack traces since in most cases it's not relevant. + +### Setup + +```php + 'vendor/phpunit/phpunit/src/Framework/TestSuite.php', + 'line' => 685, // optional, if not set all frames including the file will be excluded + ], +]); +``` diff --git a/src/Processor/DatadogProcessor.php b/src/Processor/DatadogProcessor.php index 483fe1a..a74f71a 100644 --- a/src/Processor/DatadogProcessor.php +++ b/src/Processor/DatadogProcessor.php @@ -8,16 +8,107 @@ final class DatadogProcessor implements ProcessorInterface { + public function __construct( + /** @var list */ + private readonly array $framesToExclude = [], + ) {} + public function __invoke(LogRecord $record): LogRecord { $context = $record->context; if (isset($context['exception']) && $context['exception'] instanceof \Throwable) { $context['error.message'] = $context['exception']->getMessage(); - $context['error.stack'] = $context['exception']->getTraceAsString(); + $context['error.stack'] = $this->buildStackTrace($context['exception']->getTrace()); $context['error.kind'] = $context['exception']::class; unset($context['exception']); + + return $record->with(context: $context); + } + + return $record; + } + + /** + * @param list", + * line?: int, + * file?: string, + * class?: string, + * args?: array, + * }> $stackTrace + */ + private function buildStackTrace(array $stackTrace): string + { + $filteredFrames = []; + + foreach ($stackTrace as $trace) { + $excludeFrame = false; + foreach ($this->framesToExclude as $exclude) { + if (!isset($trace['file'])) { + break; + } + if (!str_ends_with($trace['file'], $exclude['file'])) { + continue; + } + if (isset($trace['line'], $exclude['line']) && $trace['line'] !== $exclude['line']) { + continue; + } + $excludeFrame = true; + break; + } + if (!$excludeFrame) { + $filteredFrames[] = $trace; + } } - return $record->with(context: $context); + return $this->renderStackTrace($filteredFrames); + } + + /** + * @param list", + * line?: int, + * file?: string, + * class?: string, + * args?: array, + * }> $frames + */ + private function renderStackTrace(array $frames): string + { + $rtn = ''; + foreach ($frames as $index => $frame) { + $file = '[internal function]'; + if (isset($frame['file'], $frame['line'])) { + $file = sprintf('%s(%d)', $frame['file'], $frame['line']); + } + $renderedArguments = ''; + if (isset($frame['args'])) { + $args = []; + foreach ($frame['args'] as $arg) { + $args[] = match (true) { + is_string($arg) => "'" . $arg . "'", + is_array($arg) => 'Array', + is_null($arg) => 'NULL', + is_bool($arg) => $arg ? 'true' : 'false', + is_object($arg) => get_class($arg), + is_resource($arg) => get_resource_type($arg), + default => $arg, + }; + } + $renderedArguments = implode(', ', $args); + } + $rtn .= sprintf( + "#%d %s: %s%s%s(%s)\n", + $index, + $file, + $frame['class'] ?? '', + $frame['type'] ?? '', + $frame['function'], + $renderedArguments, + ); + } + return $rtn; } } diff --git a/tests/Processor/DataDogProcessorTest.php b/tests/Processor/DataDogProcessorTest.php new file mode 100644 index 0000000..fd9e595 --- /dev/null +++ b/tests/Processor/DataDogProcessorTest.php @@ -0,0 +1,101 @@ + $exception, + ], + ); + $newLogRecord = $processor($logRecord); + + $this->assertNotSame($logRecord, $newLogRecord); + + $this->assertArrayHasKey('error.message', $newLogRecord->context); + $this->assertArrayHasKey('error.stack', $newLogRecord->context); + $this->assertArrayHasKey('error.kind', $newLogRecord->context); + + $this->assertSame('test exception', $newLogRecord->context['error.message']); + $this->assertSame('RuntimeException', $newLogRecord->context['error.kind']);; + } + + public function testNoChangeIfContextDontHaveException(): void + { + $processor = new DataDogProcessor(); + $logRecord = new LogRecord( + new \DateTimeImmutable(), + 'test', + Level::Info, + 'test', + [ + 'test' => 1, + ], + ); + $newLogRecord = $processor($logRecord); + + $this->assertSame($logRecord, $newLogRecord); + } + + public function testNoChangeIfContextHaveStrangeExceptionType(): void + { + $processor = new DataDogProcessor(); + $logRecord = new LogRecord( + new \DateTimeImmutable(), + 'test', + Level::Info, + 'test', + [ + 'exception' => 1, + ], + ); + $newLogRecord = $processor($logRecord); + + $this->assertSame($logRecord, $newLogRecord); + } + + public function testExcludeFrames(): void + { + $processor = new DataDogProcessor([ + [ + 'file' => 'vendor/phpunit/phpunit/src/Framework/TestSuite.php', + ] + ]); + $exception = new \RuntimeException('test exception'); + $logRecord = new LogRecord( + new \DateTimeImmutable(), + 'test', + Level::Info, + 'test', + [ + 'exception' => $exception, + ], + ); + $newLogRecord = $processor($logRecord); + + $this->assertNotSame($logRecord, $newLogRecord); + + $this->assertArrayHasKey('error.message', $newLogRecord->context); + $this->assertArrayHasKey('error.stack', $newLogRecord->context); + $this->assertArrayHasKey('error.kind', $newLogRecord->context); + + $this->assertSame('test exception', $newLogRecord->context['error.message']); + $this->assertSame('RuntimeException', $newLogRecord->context['error.kind']);; + + $this->assertStringNotContainsString('TestSuite.php', $newLogRecord->context['error.stack']); + } +}