Tuesday, October 20, 2020

Sentry and amphp

Sentry is an Application Monitoring and Error Tracking Software. They provide an easy to use PHP SDK, but it is only useful for traditional web applications, not for asynchronous, non-blocking environments like in amphp. This is how I made is running for my specific use-case. It may not be a perfect solution and your use-case can be different.

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!