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!