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..c350bba 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, @@ -118,8 +115,7 @@ public function initializeObject(): void FLOW_PATH_ROOT . '/Packages/Framework/Neos.Flow/Classes/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())); @@ -213,6 +189,7 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar $captureException = (!in_array(get_class($throwable), $this->excludeExceptionTypes, true)); if ($captureException) { + $this->setTags(); $this->configureScope($extraData, $tags); if ($throwable instanceof Exception && $throwable->getStatusCode() === 404) { SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { @@ -226,43 +203,34 @@ public function captureThrowable(Throwable $throwable, array $extraData = [], ar $message = 'ignored'; } return new CaptureResult( - true, + true, $message, (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() ]); - $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 $sentryEventId; + return new CaptureResult( + true, + $message, + (string)$sentryEventId + ); } private function configureScope(array $extraData, array $tags): void @@ -326,7 +294,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..63abb36 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -10,8 +10,11 @@ Flownative: 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..8406f33 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: @@ -71,14 +80,36 @@ similar to the following message: ## 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 +121,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": {