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!

4 comments:

  1. Assignments are very common in schools and colleges and some assignments are easy to complete and take no time. There are also complicated assignments that take very long and they are very complicated and demand a deep understanding of the subject. Accounting assignments are very hard and these assignments demand much attention because the values have to be correct and there if one number is entered incorrectly, the whole answer is affected.

    If you don't have enough time to complete your assignments, you can get accounting assignment help from accounting assignment help online websites and these websites connect you to an accounts expert who will complete the assignment for you and make sure that all the problems are correct. Any kind of help with the accounting assignment is valuable because there are various steps and the procedure is very lengthy. It is also important to follow a proper and neat format and most of the problems need to be explained too. When you hire an expert to complete your assignment they take care of all these requirements.

    ReplyDelete
  2. This is a very good tips especially to those new to blogosphere, brief and accurate information… Thanks for sharing this one. A must read article.
    Moviesda

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete
  4. Pleased to meet you all. As a student before, I could only dream of not having to complete online projects and other homework assignments And now, it's a fact of life. A few years after joining the site, I began utilizing a program that allowed me to pay someone to take my online exam. In the smallest amount of time feasible, employees do their tasks in a highly effective manner. Definitely check out this website.

    ReplyDelete