First of all, I needed to rewrite a bunch of classes from the Sentry SDK (I am using ^3.0). Note that I don't implement their interfaces!
Client.php
<?php use Amp\Promise; use Jean85\PrettyVersions; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Sentry\Event; use Sentry\EventHint; use Sentry\EventType; use Sentry\ExceptionDataBag; use Sentry\ExceptionMechanism; use Sentry\Integration\IntegrationInterface; use Sentry\Integration\IntegrationRegistry; use Sentry\Options; use Sentry\Serializer\RepresentationSerializer; use Sentry\Serializer\RepresentationSerializerInterface; use Sentry\Serializer\Serializer; use Sentry\Serializer\SerializerInterface; use Sentry\Severity; use Sentry\StacktraceBuilder; use Sentry\State\Scope; class Client { /** * The version of the protocol to communicate with the Sentry server. */ public const PROTOCOL_VERSION = '7'; /** * The identifier of the SDK. */ public const SDK_IDENTIFIER = 'sentry.php'; private Options $options; private Transport $transport; private LoggerInterface $logger; private array $integrations; private SerializerInterface $serializer; private RepresentationSerializerInterface $representationSerializer; private StacktraceBuilder $stacktraceBuilder; private string $sdkIdentifier; private string $sdkVersion; public function __construct( Options $options, Transport $transport, ?string $sdkIdentifier = null, ?string $sdkVersion = null, ?SerializerInterface $serializer = null, ?RepresentationSerializerInterface $representationSerializer = null, ?LoggerInterface $logger = null ) { $this->options = $options; $this->transport = $transport; $this->logger = $logger ?? new NullLogger(); $this->integrations = IntegrationRegistry::getInstance()->setupIntegrations($options, $this->logger); $this->serializer = $serializer ?? new Serializer($this->options); $this->representationSerializer = $representationSerializer ?? new RepresentationSerializer($this->options); $this->stacktraceBuilder = new StacktraceBuilder($options, $this->representationSerializer); $this->sdkIdentifier = $sdkIdentifier ?? self::SDK_IDENTIFIER; $this->sdkVersion = $sdkVersion ?? PrettyVersions::getVersion(PrettyVersions::getRootPackageName())->getPrettyVersion(); } public function getOptions(): Options { return $this->options; } public function captureMessage(string $message, ?Severity $level = null, ?Scope $scope = null): void { $event = Event::createEvent(); $event->setMessage($message); $event->setLevel($level); $this->captureEvent($event, null, $scope); } public function captureException(\Throwable $exception, ?Scope $scope = null): void { $this->captureEvent(Event::createEvent(), EventHint::fromArray([ 'exception' => $exception, ]), $scope); } public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): void { $event = $this->prepareEvent($event, $hint, $scope); if (null === $event) { return; } Promise\rethrow($this->transport->send($event)); } public function captureLastError(?Scope $scope = null): void { $error = \error_get_last(); if (null === $error || ! isset($error['message'][0])) { return; } $exception = new \ErrorException(@$error['message'], 0, @$error['type'], @$error['file'], @$error['line']); $this->captureException($exception, $scope); } public function getIntegration(string $className): ?IntegrationInterface { return $this->integrations[$className] ?? null; } public function flush(?int $timeout = null): Promise { return $this->transport->close($timeout); } private function prepareEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): ?Event { if (null !== $hint) { if (null !== $hint->exception && empty($event->getExceptions())) { $this->addThrowableToEvent($event, $hint->exception); } if (null !== $hint->stacktrace && null === $event->getStacktrace()) { $event->setStacktrace($hint->stacktrace); } } $this->addMissingStacktraceToEvent($event); $event->setSdkIdentifier($this->sdkIdentifier); $event->setSdkVersion($this->sdkVersion); $event->setServerName($this->options->getServerName()); $event->setRelease($this->options->getRelease()); $event->setTags($this->options->getTags()); $event->setEnvironment($this->options->getEnvironment()); $sampleRate = $this->options->getSampleRate(); if (EventType::transaction() !== $event->getType() && $sampleRate < 1 && \mt_rand(1, 100) / 100.0 > $sampleRate) { $this->logger->info('The event will be discarded because it has been sampled.', ['event' => $event]); return null; } if (null !== $scope) { $previousEvent = $event; $event = $scope->applyToEvent($event, $hint); if (null === $event) { $this->logger->info('The event will be discarded because one of the event processors returned "null".', ['event' => $previousEvent]); return null; } } $previousEvent = $event; $event = ($this->options->getBeforeSendCallback())($event); if (null === $event) { $this->logger->info('The event will be discarded because the "before_send" callback returned "null".', ['event' => $previousEvent]); } return $event; } private function addMissingStacktraceToEvent(Event $event): void { if (! $this->options->shouldAttachStacktrace()) { return; } // We should not add a stacktrace when the event already has one or contains exceptions if (null !== $event->getStacktrace() || ! empty($event->getExceptions())) { return; } $event->setStacktrace($this->stacktraceBuilder->buildFromBacktrace( \debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), __FILE__, __LINE__ - 3 )); } private function addThrowableToEvent(Event $event, \Throwable $exception): void { if ($exception instanceof \ErrorException) { $event->setLevel(Severity::fromError($exception->getSeverity())); } $exceptions = []; do { $exceptions[] = new ExceptionDataBag( $exception, $this->stacktraceBuilder->buildFromException($exception), new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, true) ); } while ($exception = $exception->getPrevious()); $event->setExceptions($exceptions); } }
Hub.php
<?php use Sentry\Breadcrumb; use Sentry\Event; use Sentry\EventHint; use Sentry\EventId; use Sentry\Integration\IntegrationInterface; use Sentry\Severity; use Sentry\State\Scope; use Sentry\Tracing\SamplingContext; use Sentry\Tracing\Span; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; class Hub { /** * @var Layer[] The stack of client/scope pairs */ private array $stack = []; public function __construct(Client $client, ?Scope $scope = null) { $this->stack[] = new Layer($client, $scope ?? new Scope()); } public function getClient(): Client { return $this->getStackTop()->getClient(); } public function pushScope(): Scope { $clonedScope = clone $this->getScope(); $this->stack[] = new Layer($this->getClient(), $clonedScope); return $clonedScope; } public function popScope(): bool { if (1 === \count($this->stack)) { return false; } return null !== \array_pop($this->stack); } public function withScope(callable $callback): void { $scope = $this->pushScope(); try { $callback($scope); } finally { $this->popScope(); } } public function configureScope(callable $callback): void { $callback($this->getScope()); } public function captureMessage(string $message, ?Severity $level = null): void { $this->getClient()->captureMessage($message, $level, $this->getScope()); } public function captureException(\Throwable $exception): void { $this->getClient()->captureException($exception, $this->getScope()); } public function captureEvent(Event $event, ?EventHint $hint = null): void { $this->getClient()->captureEvent($event, $hint, $this->getScope()); } public function captureLastError(): ?EventId { $this->getClient()->captureLastError($this->getScope()); } public function addBreadcrumb(Breadcrumb $breadcrumb): bool { $client = $this->getClient(); if (null === $client) { return false; } $options = $client->getOptions(); $beforeBreadcrumbCallback = $options->getBeforeBreadcrumbCallback(); $maxBreadcrumbs = $options->getMaxBreadcrumbs(); if ($maxBreadcrumbs <= 0) { return false; } $breadcrumb = $beforeBreadcrumbCallback($breadcrumb); if (null !== $breadcrumb) { $this->getScope()->addBreadcrumb($breadcrumb, $maxBreadcrumbs); } return null !== $breadcrumb; } public function getIntegration(string $className): ?IntegrationInterface { $client = $this->getClient(); if (null !== $client) { return $client->getIntegration($className); } return null; } public function startTransaction(TransactionContext $context): Transaction { $transaction = new Transaction($context, $this); $client = $this->getClient(); $options = null !== $client ? $client->getOptions() : null; if (null === $options || ! $options->isTracingEnabled()) { $transaction->setSampled(false); return $transaction; } $samplingContext = SamplingContext::getDefault($context); $tracesSampler = $options->getTracesSampler(); $sampleRate = null !== $tracesSampler ? $tracesSampler($samplingContext) : $this->getSampleRate($samplingContext->getParentSampled(), $options->getTracesSampleRate()); if (! $this->isValidSampleRate($sampleRate)) { $transaction->setSampled(false); return $transaction; } if (0.0 === $sampleRate) { $transaction->setSampled(false); return $transaction; } $transaction->setSampled(\mt_rand(0, \mt_getrandmax() - 1) / \mt_getrandmax() < $sampleRate); if (! $transaction->getSampled()) { return $transaction; } $transaction->initSpanRecorder(); return $transaction; } public function getTransaction(): ?Transaction { return $this->getScope()->getTransaction(); } public function setSpan(?Span $span): Hub { $this->getScope()->setSpan($span); return $this; } public function getSpan(): ?Span { return $this->getScope()->getSpan(); } private function getScope(): Scope { return $this->getStackTop()->getScope(); } private function getStackTop(): Layer { return $this->stack[\count($this->stack) - 1]; } private function getSampleRate(?bool $hasParentBeenSampled, float $fallbackSampleRate): float { if (true === $hasParentBeenSampled) { return 1; } if (false === $hasParentBeenSampled) { return 0; } return $fallbackSampleRate; } private function isValidSampleRate(float $sampleRate): bool { if ($sampleRate < 0 || $sampleRate > 1) { return false; } return true; } }
HubFactory.php
<?php class HubFactory { private HttpClient $httpClient; private LoggerInterface $logger; private ?string $dsn; private string $environment; private string $projectDir; public function __construct( HttpClient $httpClient, LoggerInterface $logger, ?string $dsn, string $environment, string $projectDir ) { $this->httpClient = $httpClient; $this->logger = $logger; $this->dsn = $dsn; $this->environment = $environment; $this->projectDir = $projectDir; } public function create(): Hub { $options = new Options( [ 'dsn' => $this->dsn, 'environment' => $this->environment, 'default_integrations' => false, 'in_app_exclude' => [ $this->projectDir, ], 'in_app_include' => [ $this->projectDir . '/api/src', ], 'max_request_body_size' => 'none', 'send_default_pii' => true, 'context_lines' => 10, 'max_value_length' => 2 ** 14, 'tags' => [ 'php_uname' => PHP_OS, 'php_sapi_name' => PHP_SAPI, 'php_version' => PHP_VERSION, ], ] ); $options->setIntegrations([new FrameContextifierIntegration()]); $client = new Client( $options, new Transport( $options, $this->httpClient, new PayloadSerializer(), $this->logger ), null, null, null, null, $this->logger ); return new Hub($client, null); } }
Layer.php
<?php use Sentry\State\Scope; class Layer { private Client $client; private Scope $scope; public function __construct(Client $client, Scope $scope) { $this->client = $client; $this->scope = $scope; } public function getClient(): Client { return $this->client; } public function getScope(): Scope { return $this->scope; } public function setScope(Scope $scope): self { $this->scope = $scope; return $this; } }
Transport.php
<?php class Transport { private Options $options; private HttpClient $httpClient; private PayloadSerializerInterface $payloadSerializer; private LoggerInterface $logger; private array $pendingRequests = []; public function __construct( Options $options, HttpClient $httpClient, PayloadSerializerInterface $payloadSerializer, ?LoggerInterface $logger = null ) { $this->options = $options; $this->httpClient = $httpClient; $this->payloadSerializer = $payloadSerializer; $this->logger = $logger ?? new NullLogger(); } public function send(Event $event): Promise { $dsn = $this->options->getDsn(); if (null === $dsn) { throw new \RuntimeException(\sprintf('The DSN option must be set to use the "%s" transport.', self::class)); } if (EventType::transaction() === $event->getType()) { $request = new Request( $dsn->getEnvelopeApiEndpointUrl(), 'POST', $this->payloadSerializer->serialize($event) ); $request->addHeader('Content-Type', 'application/x-sentry-envelope'); } else { $request = new Request( $dsn->getStoreApiEndpointUrl(), 'POST', $this->payloadSerializer->serialize($event) ); $request->addHeader('Content-Type', 'application/json'); } return call(function () use ($event, $request): \Generator { try { $this->authenticate($request); $key = \array_key_last($this->pendingRequests) + 1; $this->pendingRequests[$key] = $request; /** @var \Amp\Http\Client\Response $response */ $response = yield $this->httpClient->request($request); } catch (\Throwable $exception) { $this->logger->error( \sprintf('Failed to send the event to Sentry. Reason: "%s".', $exception->getMessage()), ['exception' => $exception, 'event' => $event] ); return new Success(); } unset($this->pendingRequests[$key]); if ($response->getStatus() < 200 || $response->getStatus() >= 300) { $msg = \sprintf( 'Failed to send the event to Sentry. Received status code: %d', $response->getStatus() ); $this->logger->error($msg); return new Success(); } return new Success(); }); } public function close(?int $timeout = null): Promise { $promise = Promise\timeout(Promise\all($this->pendingRequests), $timeout); $promise->onResolve(function () { $this->pendingRequests = []; }); return $promise; } private function authenticate(Request $request): void { $dsn = $this->options->getDsn(); if (null === $dsn) { return; } $data = [ 'sentry_version' => Client::PROTOCOL_VERSION, 'sentry_key' => $dsn->getPublicKey(), ]; if (null !== $dsn->getSecretKey()) { $data['sentry_secret'] = $dsn->getSecretKey(); } $headers = []; foreach ($data as $headerKey => $headerValue) { $headers[] = $headerKey . '=' . $headerValue; } $request->addHeader('X-Sentry-Auth', 'Sentry ' . \implode(', ', $headers)); } }
Next, we need to have an PSR-Logger (for amphp) and the http-client, we put have this early somewhere in our bootstrap file:
bootstrap.php
<?php // Creating a log handler in this way allows the script to be run in a cluster or standalone. if (Cluster::isWorker()) { $logHandler = Cluster::createLogHandler(); } else { $logHandler = new StreamHandler(ByteStream\getStdout()); $logHandler->setFormatter(new ConsoleFormatter()); } $logger = new Logger('worker-' . Cluster::getId()); $logger->pushHandler($logHandler); $httpClient = HttpClientBuilder::buildDefault();
This is how to build Sentry now:
<?php $hubFactory = new HubFactory( $httpClient, $logger, \getenv('SENTRY_DSN'), \getenv('APPLICATION_ENV'), __DIR__ . '/../../' ); global $sentry; $sentry = $hubFactory->create(); function captureException(\Throwable $e): void { global $sentry; $sentry->captureException($e); } function captureExceptionWithScope(callable $callback, \Throwable $e): void { global $sentry; $sentry->withScope(function (Scope $scope) use ($callback, $sentry, $e) { $callback($scope); $sentry->captureException($e); }); } function captureExceptionFromRequest(\Throwable $e, Request $request): Promise { return call(function () use ($e, $request): Generator { $body = yield $request->getBody()->buffer(); captureExceptionWithScope( function (Scope $scope) use ($request, $body) { $scope->setExtra( 'request', [ 'uri' => $request->getUri()->getPath(), 'query' => $request->getUri()->getQuery(), 'post' => $body, ] ); // note that this is how I store authentication information on // the request, this might be different for you if ($request->hasAttribute('auth_info')) { $info = $request->getAttribute('auth_info'); $scope->setUser([ 'email' => $info['email'], 'name' => $info['name'], ]); } }, $e ); }); }
Now within some background jobs, I can do this:
<?php try { // something } catch (Throwable $e) { captureException($e); }And to catch exceptions from web requests:
<?php try { // something } catch (Throwable $e) { yield captureExceptionFromRequest($e, $request); }
That's it!