From 0070bf41d22764dceb4b397308dd166774fe2f10 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 1 Dec 2025 12:54:16 +0100 Subject: [PATCH] feat(client-reports): Add support for client reports --- src/Client.php | 4 +- src/ClientReport/ClientReport.php | 54 ++++++++++ src/ClientReport/ClientReportAggregator.php | 51 +++++++++ src/ClientReport/Reason.php | 102 ++++++++++++++++++ src/Event.php | 29 +++++ src/EventType.php | 6 ++ src/Logs/LogsAggregator.php | 8 ++ .../EnvelopItems/ClientReportItem.php | 30 ++++++ src/Serializer/PayloadSerializer.php | 34 +++--- src/Transport/DataCategory.php | 74 +++++++++++++ src/Transport/HttpTransport.php | 8 ++ 11 files changed, 386 insertions(+), 14 deletions(-) create mode 100644 src/ClientReport/ClientReport.php create mode 100644 src/ClientReport/ClientReportAggregator.php create mode 100644 src/ClientReport/Reason.php create mode 100644 src/Serializer/EnvelopItems/ClientReportItem.php create mode 100644 src/Transport/DataCategory.php diff --git a/src/Client.php b/src/Client.php index 74c8cfa77..7574d0579 100644 --- a/src/Client.php +++ b/src/Client.php @@ -178,7 +178,9 @@ public function captureException(\Throwable $exception, ?Scope $scope = null, ?E */ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): ?EventId { - $event = $this->prepareEvent($event, $hint, $scope); + if ($event->getType() !== EventType::clientReport()) { + $event = $this->prepareEvent($event, $hint, $scope); + } if ($event === null) { return null; diff --git a/src/ClientReport/ClientReport.php b/src/ClientReport/ClientReport.php new file mode 100644 index 000000000..202efc586 --- /dev/null +++ b/src/ClientReport/ClientReport.php @@ -0,0 +1,54 @@ +category = $category; + $this->reason = $reason; + $this->quantity = $quantity; + } + + /** + * @return string + */ + public function getCategory(): string + { + return $this->category; + } + + /** + * @return int + */ + public function getQuantity(): int + { + return $this->quantity; + } + + /** + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + +} diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php new file mode 100644 index 000000000..3cb0db1fd --- /dev/null +++ b/src/ClientReport/ClientReportAggregator.php @@ -0,0 +1,51 @@ +reports[(string) $category][(string) $reason] = ($this->reports[(string) $category][(string) $reason] ?? 0) + $quantity; + } + + public function flush(): void + { + $reports = []; + foreach ($this->reports as $category => $reasons) { + foreach ($reasons as $reason => $quantity) { + $reports[] = new ClientReport($category, $reason, $quantity); + } + } + $event = Event::createClientReport(); + $event->setClientReports($reports); + + HubAdapter::getInstance()->captureEvent($event); + $this->reports = []; + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/ClientReport/Reason.php b/src/ClientReport/Reason.php new file mode 100644 index 000000000..c29f34d3f --- /dev/null +++ b/src/ClientReport/Reason.php @@ -0,0 +1,102 @@ +value = $value; + } + + public static function queueOverflow(): self + { + return self::getInstance('queue_overflow'); + } + + public static function cacheOverflow(): self + { + return self::getInstance('cache_overflow'); + } + + public static function bufferOverflow(): self + { + return self::getInstance('buffer_overflow'); + } + + public static function ratelimitBackoff(): self + { + return self::getInstance('ratelimit_backoff'); + } + + public static function networkError(): self + { + return self::getInstance('network_error'); + } + + public static function sampleRate(): self + { + return self::getInstance('sample_rate'); + } + + public static function beforeSend(): self + { + return self::getInstance('before_send'); + } + + public static function eventProcessor(): self + { + return self::getInstance('event_processor'); + } + + public static function sendError(): self + { + return self::getInstance('send_error'); + } + + public static function internalSdkError(): self + { + return self::getInstance('internal_sdk_error'); + } + + public static function insufficientData(): self + { + return self::getInstance('insufficient_data'); + } + + public static function backpressure(): self + { + return self::getInstance('backpressure'); + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } + +} diff --git a/src/Event.php b/src/Event.php index 5244f945c..678bd7907 100644 --- a/src/Event.php +++ b/src/Event.php @@ -4,6 +4,7 @@ namespace Sentry; +use Sentry\ClientReport\ClientReport; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; @@ -204,6 +205,11 @@ final class Event */ private $profile; + /** + * @var ClientReport[] + */ + private $clientReports; + private function __construct(?EventId $eventId, EventType $eventType) { $this->id = $eventId ?? EventId::generate(); @@ -249,6 +255,11 @@ public static function createMetrics(?EventId $eventId = null): self return new self($eventId, EventType::metrics()); } + public static function createClientReport(?EventId $eventId = null): self + { + return new self($eventId, EventType::clientReport()); + } + /** * Gets the ID of this event. */ @@ -973,4 +984,22 @@ public function getTraceId(): ?string return null; } + + /** + * @param ClientReport[] $clientReports + */ + public function setClientReports(array $clientReports): self + { + $this->clientReports = $clientReports; + + return $this; + } + + /** + * @return ClientReport[] + */ + public function getClientReports(): array + { + return $this->clientReports; + } } diff --git a/src/EventType.php b/src/EventType.php index 3c2d13fb3..b0e185cf1 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -55,6 +55,11 @@ public static function metrics(): self return self::getInstance('metrics'); } + public static function clientReport(): self + { + return self::getInstance('client_report'); + } + /** * List of all cases on the enum. * @@ -68,6 +73,7 @@ public static function cases(): array self::checkIn(), self::logs(), self::metrics(), + self::clientReport(), ]; } diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index e2fb5ea78..92bff3e47 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -6,11 +6,14 @@ use Sentry\Attributes\Attribute; use Sentry\Client; +use Sentry\ClientReport\ClientReportAggregator; +use Sentry\ClientReport\Reason; use Sentry\Event; use Sentry\EventId; use Sentry\SentrySdk; use Sentry\State\HubInterface; use Sentry\State\Scope; +use Sentry\Transport\DataCategory; use Sentry\Util\Arr; use Sentry\Util\Str; @@ -35,6 +38,11 @@ public function add( array $values = [], array $attributes = [] ): void { + if (\count($this->logs) > 5) { + ClientReportAggregator::getInstance()->add(DataCategory::logBytes(), Reason::bufferOverflow(), 1); + + return; + } $timestamp = microtime(true); $hub = SentrySdk::getCurrentHub(); diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php new file mode 100644 index 000000000..4518e5842 --- /dev/null +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -0,0 +1,30 @@ +getClientReports(); + + $headers = ['type' => 'client_report']; + $body = [ + 'timestamp' => time(), + 'discarded_events' => array_map(function (ClientReport $report) { + return [ + 'category' => $report->getCategory(), + 'reason' => $report->getReason(), + 'quantity' => $report->getQuantity(), + ]; + }, $reports), + ]; + + return \sprintf("%s\n%s", json_encode($headers), json_encode($body)); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 4878cc767..06ee80606 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -8,6 +8,7 @@ use Sentry\EventType; use Sentry\Options; use Sentry\Serializer\EnvelopItems\CheckInItem; +use Sentry\Serializer\EnvelopItems\ClientReportItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; use Sentry\Serializer\EnvelopItems\ProfileItem; @@ -38,21 +39,25 @@ public function __construct(Options $options) */ public function serialize(Event $event): string { - // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers - $envelopeHeader = [ - 'event_id' => (string) $event->getId(), - 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'dsn' => (string) $this->options->getDsn(), - 'sdk' => $event->getSdkPayload(), - ]; + if ($event->getType() !== EventType::clientReport()) { + // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers + $envelopeHeader = [ + 'event_id' => (string) $event->getId(), + 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'dsn' => (string) $this->options->getDsn(), + 'sdk' => $event->getSdkPayload(), + ]; - $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); - if ($dynamicSamplingContext instanceof DynamicSamplingContext) { - $entries = $dynamicSamplingContext->getEntries(); + $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); + if ($dynamicSamplingContext instanceof DynamicSamplingContext) { + $entries = $dynamicSamplingContext->getEntries(); - if (!empty($entries)) { - $envelopeHeader['trace'] = $entries; + if (!empty($entries)) { + $envelopeHeader['trace'] = $entries; + } } + } else { + $envelopeHeader = []; } $items = []; @@ -73,8 +78,11 @@ public function serialize(Event $event): string case EventType::logs(): $items[] = LogsItem::toEnvelopeItem($event); break; + case EventType::clientReport(): + $items[] = ClientReportItem::toEnvelopeItem($event); + break; } - return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); + return \sprintf("%s\n%s", JSON::encode($envelopeHeader, \JSON_FORCE_OBJECT), implode("\n", array_filter($items))); } } diff --git a/src/Transport/DataCategory.php b/src/Transport/DataCategory.php new file mode 100644 index 000000000..dbfde7026 --- /dev/null +++ b/src/Transport/DataCategory.php @@ -0,0 +1,74 @@ +value = $value; + } + + public static function error(): self + { + return self::getInstance('error'); + } + + public static function transaction(): self + { + return self::getInstance('transaction'); + } + + // TODO: not sure if this should be called monitor or checkIn. + public static function checkIn(): self + { + return self::getInstance('monitor'); + } + + public static function logItem(): self + { + return self::getInstance('log_item'); + } + + public static function logBytes(): self + { + return self::getInstance('log_bytes'); + } + + public static function profile(): self + { + return self::getInstance('profile'); + } + + public static function metric(): self + { + return self::getInstance('trace_metric'); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value) + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index f47867fe8..d95240d75 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -6,7 +6,9 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Sentry\ClientReport\FireAndForgetClient; use Sentry\Event; +use Sentry\EventType; use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Request; use Sentry\Options; @@ -28,6 +30,11 @@ class HttpTransport implements TransportInterface */ private $httpClient; + /** + * @var HttpClientInterface Fire and Forget client so we don't have to wait for client report sending + */ + private $clientReportClient; + /** * @var PayloadSerializerInterface The event serializer */ @@ -60,6 +67,7 @@ public function __construct( $this->payloadSerializer = $payloadSerializer; $this->logger = $logger ?? new NullLogger(); $this->rateLimiter = new RateLimiter($this->logger); + $this->clientReportClient = new FireAndForgetClient(); } /**