diff --git a/Classes/Command/SentryCommandController.php b/Classes/Command/SentryCommandController.php index 043f3ee..05cd3fc 100644 --- a/Classes/Command/SentryCommandController.php +++ b/Classes/Command/SentryCommandController.php @@ -23,6 +23,10 @@ final class SentryCommandController extends CommandController { + const TEST_MODE_MESSAGE = 'message'; + const TEST_MODE_THROW = 'throw'; + const TEST_MODE_ERROR = 'error'; + /** * @Flow\Inject * @var SentryClient @@ -39,13 +43,12 @@ final class SentryCommandController extends CommandController * * @throws SentryClientTestException */ - public function testCommand(): void + public function testCommand(string $mode = self::TEST_MODE_THROW): void { $this->output->outputLine('Testing Sentry setup …'); $this->output->outputLine('Using the following configuration:'); $options = $this->sentryClient->getOptions(); - $this->output->outputTable([ ['DSN', $options->getDsn()], ['Environment', $options->getEnvironment()], @@ -57,7 +60,24 @@ public function testCommand(): void 'Value' ]); - $eventId = $this->sentryClient->captureMessage( + switch ($mode) { + case self::TEST_MODE_MESSAGE: + $this->captureMessage(); + break; + case self::TEST_MODE_THROW: + $this->throwException(); + break; + case self::TEST_MODE_ERROR: + $this->triggerError(); + break; + default: + $this->output->outputLine('Unknown mode given'); + } + } + + private function captureMessage(): void + { + $captureResult = $this->sentryClient->captureMessage( 'Flownative Sentry Plugin Test', Severity::debug(), [ @@ -65,11 +85,34 @@ public function testCommand(): void ] ); - $this->outputLine('An informational message was sent to Sentry Event ID: #%s', [$eventId]); $this->outputLine(); - $this->outputLine('This command will now throw an exception for testing purposes.'); + if ($captureResult->suceess) { + $this->outputLine('An informational message was sent to Sentry Event ID: #%s', [$captureResult->eventId]); + } else { + $this->outputLine('Sending an informational message to Sentry failed: %s', [$captureResult->message]); + } $this->outputLine(); + } + private function throwException(): void + { + $this->outputLine(); + $this->outputLine('This command will now throw an exception for testing purposes.'); + $this->outputLine(); (new ThrowingClass())->throwException(new StringableTestArgument((string)M_PI)); } + + private function triggerError(): void + { + $this->outputLine(); + $this->outputLine('This command will now cause a return type error for testing purposes.'); + $this->outputLine(); + + $function = static function (): int { + /** @noinspection PhpStrictTypeCheckingInspection */ + return 'wrong type'; + }; + /** @noinspection PhpExpressionResultUnusedInspection */ + $function(); + } } diff --git a/Classes/Log/SentryFileBackend.php b/Classes/Log/SentryFileBackend.php index 5a02e68..b6b09ef 100644 --- a/Classes/Log/SentryFileBackend.php +++ b/Classes/Log/SentryFileBackend.php @@ -15,14 +15,13 @@ use Flownative\Sentry\SentryClientTrait; use Neos\Flow\Log\Backend\FileBackend; -use Sentry\Severity; +use Sentry\Breadcrumb; +use Sentry\SentrySdk; class SentryFileBackend extends FileBackend { use SentryClientTrait; - private bool $capturingMessage = false; - /** * Appends the given message along with the additional information into the log. * @@ -36,29 +35,50 @@ class SentryFileBackend extends FileBackend */ public function append(string $message, int $severity = LOG_INFO, $additionalData = null, ?string $packageKey = null, ?string $className = null, ?string $methodName = null): void { - if ($this->capturingMessage) { - return; + try { + SentrySdk::getCurrentHub()->addBreadcrumb( + new Breadcrumb( + $this->getBreadcrumbLevel($severity), + $this->getBreadcrumbType($severity), + basename($this->logFileUrl), + $message, + ($additionalData ?? []) + array_filter([ + 'packageKey' => $packageKey, 'className' => $className, 'methodName' => $methodName + ]), + time() + ) + ); + } catch (\Throwable $throwable) { + parent::append( + sprintf('%s (%s)', $throwable->getMessage(), $throwable->getCode()), + LOG_WARNING, + null, + 'Flownative.Sentry', + __CLASS__, + __METHOD__ + ); } - try { - $this->capturingMessage = true; + parent::append($message, $severity, $additionalData, $packageKey, $className, $methodName); + } - $sentryClient = self::getSentryClient(); - if ($severity <= LOG_NOTICE && $sentryClient) { - $sentrySeverity = match ($severity) { - LOG_WARNING => Severity::warning(), - LOG_ERR => Severity::error(), - LOG_CRIT, LOG_ALERT, LOG_EMERG => Severity::fatal(), - default => Severity::info(), - }; + private function getBreadcrumbLevel(int $severity): string + { + return match ($severity) { + LOG_EMERG, LOG_ALERT, LOG_CRIT => Breadcrumb::LEVEL_FATAL, + LOG_ERR => Breadcrumb::LEVEL_ERROR, + LOG_WARNING => Breadcrumb::LEVEL_WARNING, + LOG_NOTICE, LOG_INFO => Breadcrumb::LEVEL_INFO, + default => Breadcrumb::LEVEL_DEBUG, + }; + } - $sentryClient->captureMessage($message, $sentrySeverity, ['Additional Data' => $additionalData]); - } - parent::append($message, $severity, $additionalData, $packageKey, $className, $methodName); - } catch (\Throwable $throwable) { - echo sprintf('SentryFileBackend: %s (%s)', $throwable->getMessage(), $throwable->getCode()); - } finally { - $this->capturingMessage = false; + private function getBreadcrumbType(int $severity): string + { + if ($severity >= LOG_ERR) { + return Breadcrumb::TYPE_ERROR; } + + return Breadcrumb::TYPE_DEFAULT; } } diff --git a/Classes/Package.php b/Classes/Package.php new file mode 100644 index 0000000..ab84ffd --- /dev/null +++ b/Classes/Package.php @@ -0,0 +1,24 @@ +getSignalSlotDispatcher(); + + $dispatcher->connect(Sequence::class, 'afterInvokeStep', function ($step) { + if ($step->getIdentifier() === 'neos.flow:objectmanagement:runtime') { + // instantiate client to set up Sentry and register error handler early + /** @noinspection PhpExpressionResultUnusedInspection */ + new SentryClient(); + } + }); + } +} diff --git a/Classes/SentryClient.php b/Classes/SentryClient.php index 135a54b..55eea0a 100644 --- a/Classes/SentryClient.php +++ b/Classes/SentryClient.php @@ -17,8 +17,6 @@ use Flownative\Sentry\Context\UserContextServiceInterface; use Flownative\Sentry\Context\WithExtraDataInterface; use Flownative\Sentry\Log\CaptureResult; -use GuzzleHttp\Psr7\ServerRequest; -use Jenssegers\Agent\Agent; use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; use Neos\Flow\Error\WithReferenceCodeInterface; @@ -31,7 +29,6 @@ use Neos\Flow\Utility\Environment; use Neos\Utility\Arrays; use Psr\Log\LoggerInterface; -use Psr\Log\LogLevel; use Sentry\Event; use Sentry\EventHint; use Sentry\EventId; @@ -93,6 +90,10 @@ public function injectSettings(array $settings): void public function initializeObject(): void { + if (empty($this->dsn)) { + return; + } + $representationSerializer = new RepresentationSerializer( new Options([]) ); @@ -102,10 +103,6 @@ public function initializeObject(): void $representationSerializer ); - if (empty($this->dsn)) { - return; - } - \Sentry\init([ 'dsn' => $this->dsn, 'environment' => $this->environment, @@ -116,10 +113,9 @@ public function initializeObject(): void FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Aop/', FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Error/', FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/Log/', - FLOW_PATH_ROOT . '/Packages/Libraries/neos/flow-log/' + FLOW_PATH_ROOT . '/Packages/Libraries/neos/flow-log/', ], - 'default_integrations' => false, - 'attach_stacktrace' => true + 'attach_stacktrace' => true, ]); $client = SentrySdk::getCurrentHub()->getClient(); @@ -136,38 +132,18 @@ private function setTags(): void try { $flowPackage = $this->packageManager->getPackage('Neos.Flow'); $flowVersion = $flowPackage->getInstalledVersion(); - } catch (UnknownPackageException $e) { + } catch (UnknownPackageException) { } } if (empty($flowVersion)) { $flowVersion = FLOW_VERSION_BRANCH; } - $currentSession = null; - if ($this->sessionManager) { - $currentSession = $this->sessionManager->getCurrentSession(); - } + $currentSession = $this->sessionManager?->getCurrentSession(); SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use ($flowVersion, $currentSession): void { $scope->setTag('flow_version', $flowVersion); $scope->setTag('flow_context', (string)Bootstrap::$staticObjectManager->get(Environment::class)->getContext()); - $scope->setTag('php_version', PHP_VERSION); - - if (PHP_SAPI !== 'cli') { - $scope->setTag('uri', - (string)ServerRequest::fromGlobals()->getUri()); - - $agent = new Agent(); - $scope->setContext('client_os', [ - 'name' => $agent->platform(), - 'version' => $agent->version($agent->platform()) - ]); - - $scope->setContext('client_browser', [ - 'name' => $agent->browser(), - 'version' => $agent->version($agent->browser()) - ]); - } if ($currentSession instanceof Session && $currentSession->isStarted()) { $scope->setTag('flow_session_sha1', sha1($currentSession->getId())); @@ -188,13 +164,18 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar if (empty($this->dsn)) { return new CaptureResult( false, - 'Failed capturing message, because no Sentry DSN was set. Please check your settings.', + 'Failed capturing throwable, because no Sentry DSN was set. Please check your settings.', '' ); } - $message = ''; - $sentryEventId = ''; + if ($this->excludeException($throwable)) { + return new CaptureResult( + true, + 'Skipped capturing throwable, it is in excludeExceptionTypes', + '' + ); + } if ($throwable instanceof WithReferenceCodeInterface) { $extraData['Reference Code'] = $throwable->getReferenceCode(); @@ -211,58 +192,47 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar $tags['exception_code'] = (string)$throwable->getCode(); - $captureException = (!in_array(get_class($throwable), $this->excludeExceptionTypes, true)); - if ($captureException) { - $this->configureScope($extraData, $tags); - if ($throwable instanceof Exception && $throwable->getStatusCode() === 404) { - SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { - $scope->setLevel(Severity::warning()); - }); - } - $event = Event::createEvent(); - $this->addThrowableToEvent($throwable, $event); - $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event); - } else { - $message = 'ignored'; - } + $this->setTags(); + $this->configureScope($extraData, $tags); + $event = Event::createEvent(); + $this->addThrowableToEvent($throwable, $event); + $sentryEventId = SentrySdk::getCurrentHub()->captureEvent($event); + return new CaptureResult( - true, - $message, + true, + '', (string)$sentryEventId ); } - public function captureMessage(string $message, Severity $severity, array $extraData = [], array $tags = []): ?EventId + public function captureMessage(string $message, Severity $severity, array $extraData = [], array $tags = []): CaptureResult { if (empty($this->dsn)) { - if ($this->logger) { - $this->logger->warning('Sentry: Failed capturing message, because no Sentry DSN was set. Please check your settings.'); - } - return null; - } - - if (preg_match('/Sentry: [0-9a-f]{32}/', $message) === 1) { - return null; + return new CaptureResult( + false, + 'Failed capturing message, because no Sentry DSN was set. Please check your settings.', + '' + ); } + $this->setTags(); $this->configureScope($extraData, $tags); $eventHint = EventHint::fromArray([ - 'stacktrace' => $this->prepareStacktrace() + 'stacktrace' => $this->prepareStacktrace(), ]); - $sentryEventId = \Sentry\captureMessage($message, $severity, $eventHint); - - if ($this->logger) { - $this->logger->log( - (string)$severity, - sprintf( - '%s (Sentry: %s)', - $message, - $sentryEventId - ) - ); - } + $sentryEventId = SentrySdk::getCurrentHub()->captureMessage($message, $severity, $eventHint); + + return new CaptureResult( + true, + $message, + (string)$sentryEventId + ); + } - return $sentryEventId; + private function excludeException(\Throwable $throwable): bool + { + $excludedExceptions = array_keys(array_filter($this->excludeExceptionTypes)); + return in_array(get_class($throwable), $excludedExceptions, true); } private function configureScope(array $extraData, array $tags): void @@ -282,7 +252,6 @@ private function configureScope(array $extraData, array $tags): void $scope->setTag($tagKey, $tagValue); } $scope->setUser($userContext->toArray()); - $scope->setLevel(null); }); } @@ -326,7 +295,7 @@ private function prepareStacktrace(\Throwable $throwable = null): Stacktrace $frame->getRawFunctionName(), $frame->getAbsoluteFilePath(), $frame->getVars(), - strpos($classPathAndFilename, 'Packages/Framework/') === false + !str_contains($classPathAndFilename, 'Packages/Framework/') ); } return new Stacktrace($frames); diff --git a/Classes/Test/JsonSerializableTestArgument.php b/Classes/Test/JsonSerializableTestArgument.php index 72cdcec..ad26c11 100644 --- a/Classes/Test/JsonSerializableTestArgument.php +++ b/Classes/Test/JsonSerializableTestArgument.php @@ -22,7 +22,7 @@ public function __construct(int $value) $this->value = $value; } - public function jsonSerialize() + public function jsonSerialize(): int { return $this->value; } diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 24b4687..f9b7559 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -5,13 +5,21 @@ Flownative: release: "%env:SENTRY_RELEASE%" sampleRate: 1.0 capture: - excludeExceptionTypes: [] + excludeExceptionTypes: + 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException': true + 'Neos\Flow\Mvc\Exception\NoMatchingRouteException': true + 'Neos\Flow\Mvc\Exception\NoSuchActionException': true + 'Neos\Flow\Mvc\Exception\NoSuchControllerException': true + 'Neos\Flow\Property\Exception\TargetNotFoundException': true Neos: Flow: log: - systemLogger: - backend: Flownative\Sentry\Log\SentryFileBackend + psr3: + Neos\Flow\Log\PsrLoggerFactory: + systemLogger: + default: + class: 'Flownative\Sentry\Log\SentryFileBackend' throwables: storageClass: 'Flownative\Sentry\Log\SentryStorage' optionsByImplementation: diff --git a/README.md b/README.md index f8b0402..02ceb0b 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,13 @@ reporting of errors to [Sentry](https://www.sentry.io) ## Key Features -This package makes sure that exceptions and errors logged by the Flow -framework also end up in Sentry. This client takes some extra care to -clean up paths and filenames of stacktraces so you get good overview -while looking at an issue in the Sentry UI. +This package makes sure that throwables and exceptions logged in a Flow +application also end up in Sentry. This is done by implementing Flow's +`ThrowableStorageInterface` and configuring the default implementation. + +This packages takes some extra care to clean up paths and filenames of +stacktraces so you get a good overview while looking at an issue in the +Sentry UI. ## Installation @@ -29,7 +32,13 @@ $ composer require flownative/sentry You need to at least specify a DSN to be used as a logging target. Apart from that, you can configure the Sentry environment and release. These options can either be set in the Flow settings or, more conveniently, by -setting the respective environment variables. +setting the respective environment variables: + +- `SENTRY_DSN` +- `SENTRY_ENVIRONMENT` +- `SENTRY_RELEASE` + +The package uses these environment variables by default in the settings: ```yaml Flownative: @@ -51,34 +60,60 @@ The default is 1 – 100% percent of all errors are sampled. Throwables (that includes exceptions and runtime errors) are logged as Sentry events. You may specify a list of exceptions which should not be -recorded. If such an exception is thrown, it will only be logged as a -"notice". +recorded. ```yaml Flownative: Sentry: capture: excludeExceptionTypes: - - 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException' + 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException': true ``` -If an ignored exception is handled by this Sentry client, it is logged -similar to the following message: +By default all Flow exceptions with a status code of 404 are ignored. in case +you want to see those in Sentry, you can include them case-by-case like so: -``` -… NOTICE Exception 12345: The exception message (Ref: 202004161706040c28ae | Sentry: ignored) +```yaml +Flownative: + Sentry: + capture: + excludeExceptionTypes: + 'Neos\Flow\Mvc\Controller\Exception\InvalidControllerException': false + 'Neos\Flow\Mvc\Exception\NoSuchControllerException': false ``` ## Additional Data -Exceptions declared in an application can optionally implement -`WithAdditionalDataInterface` provided by this package. If they do, the -array returned by `getAdditionalData()` will be visible in the "additional +Exceptions declared in an application can optionally implement +`WithAdditionalDataInterface` provided by this package. If they do, the +array returned by `getAdditionalData()` will be visible in the "additional data" section in Sentry. -Note that the array must only contain values of simple types, such as +Note that the array must only contain values of simple types, such as strings, booleans or integers. +## Logging integration + +### Breadcrumb handler + +This package configures a logging backend to add messages as breadcrumbs to +be sent to Sentry when an exception happens. This provides more information +on what happened before an exception. + +For more information on breadcrumbs see the Sentry documentation at +https://docs.sentry.io/platforms/php/enriching-events/breadcrumbs/ + +### Monolog + +In case you want to store all log messages in Sentry, one way is to configure +Flow to use monolog for logging and then add the `Sentry\Monolog\Handler` to +the setup. + +Keep in mind that the breadcrumb handler provided by this package might be +disabled when doing this, depending on your configuration. Sentry provides +a monolog integration for that purpose, see `Sentry\Monolog\BreadcrumbHandler` +and https://docs.sentry.io/platforms/php/integrations/monolog/. + ## Testing the Client This package provides a command controller which allows you to log a @@ -90,35 +125,39 @@ Run the following command in your terminal to test your configuration: ./flow sentry:test Testing Sentry setup … Using the following configuration: -+-------------+------------------------------------------------------------+ -| Option | Value | -+-------------+------------------------------------------------------------+ ++-------------+----------------------------------------------------------+ +| Option | Value | ++-------------+----------------------------------------------------------+ | DSN | https://abc123456789abcdef1234567890ab@sentry.io/1234567 | -| Environment | development | -| Release | dev | -| Server Name | test_container | -| Sample Rate | 1 | -+-------------+------------------------------------------------------------+ -An informational message was sent to Sentry Event ID: #587abc123457abcd8f873b4212345678 +| Environment | development | +| Release | dev | +| Server Name | test_container | +| Sample Rate | 1 | ++-------------+----------------------------------------------------------+ This command will now throw an exception for testing purposes. -Test exception in SentryCommandController +Test exception in ThrowingClass - Type: Flownative\Sentry\Exception\SentryClientTestException - Code: 1614759519 + Type: Flownative\Sentry\Test\SentryClientTestException + Code: 1662712736 File: Data/Temporary/Development/SubContextBeach/SubContextInstance/Cache/Code/Fl - ow_Object_Classes/Flownative_Sentry_Command_SentryCommandController.php - Line: 79 + ow_Object_Classes/Flownative_Sentry_Test_ThrowingClass.php + Line: 41 Nested exception: -Test "previous" exception thrown by the SentryCommandController +Test "previous" exception in ThrowingClass Type: RuntimeException - Code: 1614759554 + Code: 1662712735 File: Data/Temporary/Development/SubContextBeach/SubContextInstance/Cache/Code/Fl - ow_Object_Classes/Flownative_Sentry_Command_SentryCommandController.php - Line: 78 + ow_Object_Classes/Flownative_Sentry_Test_ThrowingClass.php + Line: 40 -Open Data/Logs/Exceptions/2021030308325919ecbf.txt for a full stack trace. +Open Data/Logs/Exceptions/202411181211403b652e.txt for a full stack trace. ``` + +There are two more test modes for message capturing and error handling: + +- `./flow sentry:test --mode message` +- `./flow sentry:test --mode error` diff --git a/composer.json b/composer.json index 2527c06..62efd54 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,7 @@ "ext-json": "*", "php": "^8.1", "neos/flow": "^8.0 || ^9.0 || @dev", - "sentry/sentry": "^4.0", - "jenssegers/agent": "^2.6" + "sentry/sentry": "^4.0" }, "autoload": { "psr-4": {