From 0070bf41d22764dceb4b397308dd166774fe2f10 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 1 Dec 2025 12:54:16 +0100 Subject: [PATCH 01/17] 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 74c8cfa779..7574d0579a 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 0000000000..202efc5862 --- /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 0000000000..3cb0db1fd3 --- /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 0000000000..c29f34d3f2 --- /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 5244f945c8..678bd79079 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 3c2d13fb3f..b0e185cf14 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 e2fb5ea781..92bff3e47e 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 0000000000..4518e58421 --- /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 4878cc767e..06ee806061 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 0000000000..dbfde70266 --- /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 f47867fe84..d95240d757 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(); } /** From 7875accb364a18b17ff0ca874b46185f660b2370 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 9 Dec 2025 16:26:45 +0100 Subject: [PATCH 02/17] add tests --- src/Client.php | 1 + src/ClientReport/ClientReport.php | 13 +-- src/ClientReport/ClientReportAggregator.php | 36 +++++++- src/ClientReport/Reason.php | 10 +-- .../EnvelopItems/ClientReportItem.php | 2 +- src/Serializer/PayloadSerializer.php | 37 ++++---- src/Transport/DataCategory.php | 13 ++- src/Transport/HttpTransport.php | 8 -- .../ClientReportAggregatorTest.php | 86 +++++++++++++++++++ tests/Serializer/PayloadSerializerTest.php | 17 ++++ tests/StubLogger.php | 38 ++++++++ 11 files changed, 215 insertions(+), 46 deletions(-) create mode 100644 tests/ClientReport/ClientReportAggregatorTest.php create mode 100644 tests/StubLogger.php diff --git a/src/Client.php b/src/Client.php index 3b374dd20d..84e00baf78 100644 --- a/src/Client.php +++ b/src/Client.php @@ -178,6 +178,7 @@ public function captureException(\Throwable $exception, ?Scope $scope = null, ?E */ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): ?EventId { + // Client reports don't need to be augmented in the prepareEvent pipeline. if ($event->getType() !== EventType::clientReport()) { $event = $this->prepareEvent($event, $hint, $scope); } diff --git a/src/ClientReport/ClientReport.php b/src/ClientReport/ClientReport.php index 202efc5862..0b580574e5 100644 --- a/src/ClientReport/ClientReport.php +++ b/src/ClientReport/ClientReport.php @@ -1,10 +1,11 @@ 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 index 3cb0db1fd3..96b8e07daa 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -5,28 +5,56 @@ namespace Sentry\ClientReport; use Sentry\Event; -use Sentry\State\Hub; use Sentry\State\HubAdapter; use Sentry\Transport\DataCategory; class ClientReportAggregator { + /** + * @var self + */ private static $instance; /** - * Nested array for local aggregation. + * Nested array for local aggregation. The first key is the category and the second one is the reason. * - * @var array + * ``` + * [ + * 'example-category' => [ + * 'example-reason' => 10 + * ] + * ] + *``` + * + * @var array> */ private $reports = []; public function add(DataCategory $category, Reason $reason, int $quantity): void { - $this->reports[(string) $category][(string) $reason] = ($this->reports[(string) $category][(string) $reason] ?? 0) + $quantity; + $category = $category->getValue(); + $reason = $reason->getValue(); + if ($quantity <= 0) { + $client = HubAdapter::getInstance()->getClient(); + if ($client !== null) { + $logger = $client->getOptions()->getLoggerOrNullLogger(); + $logger->debug('Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', [ + 'category' => $category, + 'reason' => $reason, + 'quantity' => $quantity, + ]); + + return; + } + } + $this->reports[$category][$reason] = ($this->reports[$category][$reason] ?? 0) + $quantity; } public function flush(): void { + if (empty($this->reports)) { + return; + } $reports = []; foreach ($this->reports as $category => $reasons) { foreach ($reasons as $reason => $quantity) { diff --git a/src/ClientReport/Reason.php b/src/ClientReport/Reason.php index c29f34d3f2..3c66bf7d49 100644 --- a/src/ClientReport/Reason.php +++ b/src/ClientReport/Reason.php @@ -1,15 +1,19 @@ + */ private static $instances = []; public function __construct(string $value) @@ -77,9 +81,6 @@ public static function backpressure(): self return self::getInstance('backpressure'); } - /** - * @return string - */ public function getValue(): string { return $this->value; @@ -98,5 +99,4 @@ private static function getInstance(string $value): self return self::$instances[$value]; } - } diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php index 4518e58421..5d87b1b70c 100644 --- a/src/Serializer/EnvelopItems/ClientReportItem.php +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -15,7 +15,7 @@ public static function toEnvelopeItem(Event $event): ?string $headers = ['type' => 'client_report']; $body = [ - 'timestamp' => time(), + 'timestamp' => $event->getTimestamp(), 'discarded_events' => array_map(function (ClientReport $report) { return [ 'category' => $report->getCategory(), diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index fdb6d5912b..3204f5900c 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -40,23 +40,26 @@ public function __construct(Options $options) */ public function serialize(Event $event): string { - // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers - $envelopeHeader = [ - 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'dsn' => (string) $this->options->getDsn(), - 'sdk' => $event->getSdkPayload(), - ]; + $envelopeHeader = null; + if ($event->getType() !== EventType::clientReport()) { + // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers + $envelopeHeader = [ + 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'dsn' => (string) $this->options->getDsn(), + 'sdk' => $event->getSdkPayload(), + ]; - if ($event->getType()->requiresEventId()) { - $envelopeHeader['event_id'] = (string) $event->getId(); - } + if ($event->getType()->requiresEventId()) { + $envelopeHeader['event_id'] = (string) $event->getId(); + } - $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; + } } } @@ -86,6 +89,10 @@ public function serialize(Event $event): string break; } - return \sprintf("%s\n%s", JSON::encode($envelopeHeader, \JSON_FORCE_OBJECT), implode("\n", array_filter($items))); + if ($envelopeHeader === null) { + return \sprintf("{}\n%s", implode("\n", array_filter($items))); + } + + return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); } } diff --git a/src/Transport/DataCategory.php b/src/Transport/DataCategory.php index dbfde70266..907404f7d2 100644 --- a/src/Transport/DataCategory.php +++ b/src/Transport/DataCategory.php @@ -1,15 +1,19 @@ + */ private static $instances = []; public function __construct(string $value) @@ -53,6 +57,11 @@ public static function metric(): self return self::getInstance('trace_metric'); } + public static function internal(): self + { + return self::getInstance('internal'); + } + public function getValue(): string { return $this->value; @@ -63,7 +72,7 @@ public function __toString() return $this->value; } - private static function getInstance(string $value) + private static function getInstance(string $value): self { if (!isset(self::$instances[$value])) { self::$instances[$value] = new self($value); diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 8bcd74f778..12666ebd4b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -6,9 +6,7 @@ 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; @@ -30,11 +28,6 @@ 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 */ @@ -67,7 +60,6 @@ public function __construct( $this->payloadSerializer = $payloadSerializer; $this->logger = $logger ?? new NullLogger(); $this->rateLimiter = new RateLimiter($this->logger); - $this->clientReportClient = new FireAndForgetClient(); } /** diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php new file mode 100644 index 0000000000..5be76bb0ea --- /dev/null +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -0,0 +1,86 @@ +bindClient(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance())); + } + + public function testAddClientReport(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::error(), Reason::beforeSend(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(2, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + + $report = $reports[1]; + $this->assertSame(DataCategory::error()->getValue(), $report->getCategory()); + $this->assertSame(Reason::beforeSend()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + } + + public function testClientReportAggregation(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(1, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(40, $report->getQuantity()); + } + + public function testNegativeQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); + } + + public function testZeroQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 0); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); + } +} diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 53f3235e84..f3a8c869d6 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -9,6 +9,7 @@ use Sentry\CheckIn; use Sentry\CheckInStatus; use Sentry\Client; +use Sentry\ClientReport\ClientReport; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Event; @@ -472,5 +473,21 @@ public static function serializeAsEnvelopeDataProvider(): iterable {"items":[{"timestamp":1597790835,"trace_id":"21160e9b836d479f81611368b2aa3d2c","span_id":"d051f34163cd45fb","name":"test-distribution","value":5,"unit":"day","type":"distribution","attributes":{"foo":{"type":"string","value":"bar"}}}]} TEXT ]; + + $event = Event::createClientReport(); + $event->setClientReports([ + new ClientReport('error', 'before_send', 10), + new ClientReport('profile', 'internal_sdk_error', 50), + ]); + + yield [ + $event, + << $level, + 'message' => $message, + 'context' => $context, + ]; + } +} From 5cdbb8aa6412b6879564d8e0edf518bcfb78298a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 10 Dec 2025 11:12:12 +0100 Subject: [PATCH 03/17] tests --- tests/ClientReport/ClientReportAggregatorTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index 5be76bb0ea..8a38077f26 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -22,6 +22,7 @@ protected function setUp(): void StubLogger::$logs = []; SentrySdk::init()->bindClient(new Client(new Options([ 'logger' => StubLogger::getInstance(), + 'default_integrations' => false, ]), StubTransport::getInstance())); } @@ -70,7 +71,7 @@ public function testNegativeQuantityDiscarded(): void ClientReportAggregator::getInstance()->flush(); $this->assertEmpty(StubTransport::$events); - $this->assertCount(1, StubLogger::$logs); + $this->assertNotEmpty(StubLogger::$logs); $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); } From f30739ae1f95212394244deec3df391cbdc92a25 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 10 Dec 2025 11:16:02 +0100 Subject: [PATCH 04/17] tests --- tests/ClientReport/ClientReportAggregatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index 8a38077f26..9746d9bf5d 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -18,11 +18,11 @@ class ClientReportAggregatorTest extends TestCase { protected function setUp(): void { + ini_set('zend.exception_ignore_args', '0'); StubTransport::$events = []; StubLogger::$logs = []; SentrySdk::init()->bindClient(new Client(new Options([ 'logger' => StubLogger::getInstance(), - 'default_integrations' => false, ]), StubTransport::getInstance())); } From 0bb64a554bb8ce1be7be49dee275f3cfa4dff573 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:05:58 +0100 Subject: [PATCH 05/17] fix --- src/ClientReport/ClientReportAggregator.php | 4 +- src/Event.php | 2 +- src/Logs/LogsAggregator.php | 2 +- src/Transport/DataCategory.php | 2 +- .../ClientReportAggregatorTest.php | 49 +++++++++++++++++++ tests/Serializer/PayloadSerializerTest.php | 28 +++++++++++ 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index 96b8e07daa..be46dc02cb 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -43,9 +43,9 @@ public function add(DataCategory $category, Reason $reason, int $quantity): void 'reason' => $reason, 'quantity' => $quantity, ]); - - return; } + + return; } $this->reports[$category][$reason] = ($this->reports[$category][$reason] ?? 0) + $quantity; } diff --git a/src/Event.php b/src/Event.php index 334c34b6a9..3bd34ea04a 100644 --- a/src/Event.php +++ b/src/Event.php @@ -214,7 +214,7 @@ final class Event /** * @var ClientReport[] */ - private $clientReports; + private $clientReports = []; private function __construct(?EventId $eventId, EventType $eventType) { diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 92bff3e47e..3b41bb43a1 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -39,7 +39,7 @@ public function add( array $attributes = [] ): void { if (\count($this->logs) > 5) { - ClientReportAggregator::getInstance()->add(DataCategory::logBytes(), Reason::bufferOverflow(), 1); + ClientReportAggregator::getInstance()->add(DataCategory::logItem(), Reason::bufferOverflow(), 1); return; } diff --git a/src/Transport/DataCategory.php b/src/Transport/DataCategory.php index 907404f7d2..b2ee0884e9 100644 --- a/src/Transport/DataCategory.php +++ b/src/Transport/DataCategory.php @@ -44,7 +44,7 @@ public static function logItem(): self public static function logBytes(): self { - return self::getInstance('log_bytes'); + return self::getInstance('log_byte'); } public static function profile(): self diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index 9746d9bf5d..d00a27b57e 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -8,8 +8,11 @@ use Sentry\Client; use Sentry\ClientReport\ClientReportAggregator; use Sentry\ClientReport\Reason; +use Sentry\Logs\LogLevel; +use Sentry\Logs\LogsAggregator; use Sentry\Options; use Sentry\SentrySdk; +use Sentry\State\Hub; use Sentry\Tests\StubLogger; use Sentry\Tests\StubTransport; use Sentry\Transport\DataCategory; @@ -84,4 +87,50 @@ public function testZeroQuantityDiscarded(): void $this->assertCount(1, StubLogger::$logs); $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); } + + public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void + { + SentrySdk::setCurrentHub(new Hub()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + + SentrySdk::setCurrentHub(new Hub(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance()))); + + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertEmpty(StubLogger::$logs); + } + + public function testLogCategoriesAreCanonical(): void + { + $this->assertSame('log_item', DataCategory::logItem()->getValue()); + $this->assertSame('log_byte', DataCategory::logBytes()->getValue()); + } + + public function testLogOverflowReportsLogItemCount(): void + { + SentrySdk::init()->bindClient(new Client(new Options([ + 'enable_logs' => true, + ]), StubTransport::getInstance())); + + $logsAggregator = new LogsAggregator(); + + for ($i = 0; $i < 8; ++$i) { + $logsAggregator->add(LogLevel::info(), 'Log %d', [$i]); + } + + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(1, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::logItem()->getValue(), $report->getCategory()); + $this->assertSame(Reason::bufferOverflow()->getValue(), $report->getReason()); + $this->assertSame(2, $report->getQuantity()); + } } diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index f3a8c869d6..e7455eb052 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -474,6 +474,34 @@ public static function serializeAsEnvelopeDataProvider(): iterable TEXT ]; + $event = Event::createClientReport(); + + yield [ + $event, + <<setClientReports([ + new ClientReport('log_item', 'buffer_overflow', 1), + new ClientReport('log_byte', 'buffer_overflow', 256), + ]); + + yield [ + $event, + <<setClientReports([ new ClientReport('error', 'before_send', 10), From 967ef89b059023a706d7b174485f7476435a3102 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:15:35 +0100 Subject: [PATCH 06/17] CS --- src/Serializer/EnvelopItems/ClientReportItem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php index 5d87b1b70c..ef85545920 100644 --- a/src/Serializer/EnvelopItems/ClientReportItem.php +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -16,7 +16,7 @@ public static function toEnvelopeItem(Event $event): ?string $headers = ['type' => 'client_report']; $body = [ 'timestamp' => $event->getTimestamp(), - 'discarded_events' => array_map(function (ClientReport $report) { + 'discarded_events' => array_map(static function (ClientReport $report) { return [ 'category' => $report->getCategory(), 'reason' => $report->getReason(), From 371d711ba17d662753d24d7a143158195d90b5ae Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:27:23 +0100 Subject: [PATCH 07/17] cleanup --- src/Logs/LogsAggregator.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index e1fc14ab89..0d49d84f7f 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -38,11 +38,6 @@ public function add( array $values = [], array $attributes = [] ): void { - if (\count($this->logs) > 5) { - ClientReportAggregator::getInstance()->add(DataCategory::logItem(), Reason::bufferOverflow(), 1); - - return; - } $timestamp = microtime(true); $hub = SentrySdk::getCurrentHub(); From 811bd8f5ff3f7f9f0e1f50986ee154ebace7f42f Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:31:57 +0100 Subject: [PATCH 08/17] cleanup --- .../ClientReportAggregatorTest.php | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index d00a27b57e..86a9adba85 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -109,28 +109,4 @@ public function testLogCategoriesAreCanonical(): void $this->assertSame('log_item', DataCategory::logItem()->getValue()); $this->assertSame('log_byte', DataCategory::logBytes()->getValue()); } - - public function testLogOverflowReportsLogItemCount(): void - { - SentrySdk::init()->bindClient(new Client(new Options([ - 'enable_logs' => true, - ]), StubTransport::getInstance())); - - $logsAggregator = new LogsAggregator(); - - for ($i = 0; $i < 8; ++$i) { - $logsAggregator->add(LogLevel::info(), 'Log %d', [$i]); - } - - ClientReportAggregator::getInstance()->flush(); - - $this->assertCount(1, StubTransport::$events); - $reports = StubTransport::$events[0]->getClientReports(); - $this->assertCount(1, $reports); - - $report = $reports[0]; - $this->assertSame(DataCategory::logItem()->getValue(), $report->getCategory()); - $this->assertSame(Reason::bufferOverflow()->getValue(), $report->getReason()); - $this->assertSame(2, $report->getQuantity()); - } } From 9de7813749419983649ee08f34e344825e590adc Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:32:08 +0100 Subject: [PATCH 09/17] cleanup --- tests/ClientReport/ClientReportAggregatorTest.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index 86a9adba85..f433242e57 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -103,10 +103,4 @@ public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void $this->assertEmpty(StubTransport::$events); $this->assertEmpty(StubLogger::$logs); } - - public function testLogCategoriesAreCanonical(): void - { - $this->assertSame('log_item', DataCategory::logItem()->getValue()); - $this->assertSame('log_byte', DataCategory::logBytes()->getValue()); - } } From a38edb4264f950422ecd49fe6d664c4db1845a83 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:33:57 +0100 Subject: [PATCH 10/17] CS --- src/ClientReport/ClientReportAggregator.php | 2 +- src/Logs/LogsAggregator.php | 3 --- tests/ClientReport/ClientReportAggregatorTest.php | 6 ++---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index be46dc02cb..7a168db3d2 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -38,7 +38,7 @@ public function add(DataCategory $category, Reason $reason, int $quantity): void $client = HubAdapter::getInstance()->getClient(); if ($client !== null) { $logger = $client->getOptions()->getLoggerOrNullLogger(); - $logger->debug('Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', [ + $logger->debug('Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', [ 'category' => $category, 'reason' => $reason, 'quantity' => $quantity, diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 0d49d84f7f..8d1ab7db49 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -6,14 +6,11 @@ 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; diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index f433242e57..5e598a5437 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -8,8 +8,6 @@ use Sentry\Client; use Sentry\ClientReport\ClientReportAggregator; use Sentry\ClientReport\Reason; -use Sentry\Logs\LogLevel; -use Sentry\Logs\LogsAggregator; use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; @@ -75,7 +73,7 @@ public function testNegativeQuantityDiscarded(): void $this->assertEmpty(StubTransport::$events); $this->assertNotEmpty(StubLogger::$logs); - $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); } public function testZeroQuantityDiscarded(): void @@ -85,7 +83,7 @@ public function testZeroQuantityDiscarded(): void $this->assertEmpty(StubTransport::$events); $this->assertCount(1, StubLogger::$logs); - $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); } public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void From d1d639c354abd81f00a8dc8e3cd7ab4518182668 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:49:23 +0100 Subject: [PATCH 11/17] reset when event is sent successfully --- src/ClientReport/ClientReportAggregator.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index 7a168db3d2..b53d99ea2e 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -64,8 +64,11 @@ public function flush(): void $event = Event::createClientReport(); $event->setClientReports($reports); - HubAdapter::getInstance()->captureEvent($event); - $this->reports = []; + // Reset the client reports only if we successfully sent an event. If it fails it + // can be sent on the next flush, or it gets discarded anyway. + if (HubAdapter::getInstance()->captureEvent($event) !== null) { + $this->reports = []; + }; } public static function getInstance(): self From b848371e7aa921173a37bc21e56fb0961d53ee70 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:50:49 +0100 Subject: [PATCH 12/17] update EventType to make clear that client reports dont need event id --- src/EventType.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/EventType.php b/src/EventType.php index 5c068d0f15..76b7e8d19b 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -79,6 +79,7 @@ public function requiresEventId(): bool switch ($this) { case self::metrics(): case self::logs(): + case self::clientReport(): return false; default: return true; From 91e128dc50e9f124e9f46cee1fcf2e97598a1b9a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:55:34 +0100 Subject: [PATCH 13/17] update StubTransport to properly return the event --- tests/StubTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/StubTransport.php b/tests/StubTransport.php index 8a427622df..f80d69045a 100644 --- a/tests/StubTransport.php +++ b/tests/StubTransport.php @@ -37,7 +37,7 @@ public function send(Event $event): Result { self::$events[] = $event; - return new Result(ResultStatus::success()); + return new Result(ResultStatus::success(), $event); } public function close(?int $timeout = null): Result From 30d2fc7fb302b58d752b916bfe82bd244f40e7f0 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Feb 2026 15:56:39 +0100 Subject: [PATCH 14/17] CS --- src/ClientReport/ClientReportAggregator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index b53d99ea2e..b07593ccf1 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -68,7 +68,7 @@ public function flush(): void // can be sent on the next flush, or it gets discarded anyway. if (HubAdapter::getInstance()->captureEvent($event) !== null) { $this->reports = []; - }; + } } public static function getInstance(): self From b05c689b3dfe8cf4c3e940a4c0866d78ec7ec154 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 9 Mar 2026 15:06:43 +0100 Subject: [PATCH 15/17] review --- src/ClientReport/ClientReportAggregator.php | 2 +- .../{ClientReport.php => DiscardedEvent.php} | 2 +- src/Event.php | 8 ++--- src/EventType.php | 10 ++++++ .../EnvelopItems/ClientReportItem.php | 7 ++-- src/Transport/HttpTransport.php | 36 ++++++++++--------- tests/Serializer/PayloadSerializerTest.php | 10 +++--- 7 files changed, 44 insertions(+), 31 deletions(-) rename src/ClientReport/{ClientReport.php => DiscardedEvent.php} (97%) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index b07593ccf1..9499187953 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -58,7 +58,7 @@ public function flush(): void $reports = []; foreach ($this->reports as $category => $reasons) { foreach ($reasons as $reason => $quantity) { - $reports[] = new ClientReport($category, $reason, $quantity); + $reports[] = new DiscardedEvent($category, $reason, $quantity); } } $event = Event::createClientReport(); diff --git a/src/ClientReport/ClientReport.php b/src/ClientReport/DiscardedEvent.php similarity index 97% rename from src/ClientReport/ClientReport.php rename to src/ClientReport/DiscardedEvent.php index 0b580574e5..c2982c36d7 100644 --- a/src/ClientReport/ClientReport.php +++ b/src/ClientReport/DiscardedEvent.php @@ -4,7 +4,7 @@ namespace Sentry\ClientReport; -class ClientReport +class DiscardedEvent { /** * @var string diff --git a/src/Event.php b/src/Event.php index 3bd34ea04a..e236c82a79 100644 --- a/src/Event.php +++ b/src/Event.php @@ -4,7 +4,7 @@ namespace Sentry; -use Sentry\ClientReport\ClientReport; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; @@ -212,7 +212,7 @@ final class Event private $profile; /** - * @var ClientReport[] + * @var DiscardedEvent[] */ private $clientReports = []; @@ -991,7 +991,7 @@ public function getTraceId(): ?string } /** - * @param ClientReport[] $clientReports + * @param DiscardedEvent[] $clientReports */ public function setClientReports(array $clientReports): self { @@ -1001,7 +1001,7 @@ public function setClientReports(array $clientReports): self } /** - * @return ClientReport[] + * @return DiscardedEvent[] */ public function getClientReports(): array { diff --git a/src/EventType.php b/src/EventType.php index 76b7e8d19b..462e9d6102 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -86,6 +86,16 @@ public function requiresEventId(): bool } } + /** + * Returns false if rate limiting should not be applied. + * + * @return bool + */ + public function requiresRateLimiting(): bool + { + return $this !== self::clientReport(); + } + public function __toString(): string { return $this->value; diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php index ef85545920..c43a9ee0b4 100644 --- a/src/Serializer/EnvelopItems/ClientReportItem.php +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -4,8 +4,9 @@ namespace Sentry\Serializer\EnvelopItems; -use Sentry\ClientReport\ClientReport; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Event; +use Sentry\Util\JSON; class ClientReportItem implements EnvelopeItemInterface { @@ -16,7 +17,7 @@ public static function toEnvelopeItem(Event $event): ?string $headers = ['type' => 'client_report']; $body = [ 'timestamp' => $event->getTimestamp(), - 'discarded_events' => array_map(static function (ClientReport $report) { + 'discarded_events' => array_map(static function (DiscardedEvent $report) { return [ 'category' => $report->getCategory(), 'reason' => $report->getReason(), @@ -25,6 +26,6 @@ public static function toEnvelopeItem(Event $event): ?string }, $reports), ]; - return \sprintf("%s\n%s", json_encode($headers), json_encode($body)); + return \sprintf("%s\n%s", JSON::encode($headers), JSON::encode($body)); } } diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 12666ebd4b..02b4695fe3 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -91,26 +91,28 @@ public function send(Event $event): Result $this->logger->info(\sprintf('Sending %s to %s.', $eventDescription, $targetDescription), ['event' => $event]); $eventType = $event->getType(); - if ($this->rateLimiter->isRateLimited((string) $eventType)) { - $this->logger->warning( - \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), - ['event' => $event] - ); - - return new Result(ResultStatus::rateLimit()); - } - - // Since profiles are attached to transaction we have to check separately if they are rate limited. - // We can do this after transactions have been checked because if transactions are rate limited, - // so are profiles but not the other way around. - if ($event->getSdkMetadata('profile') !== null) { - if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { - // Just remove profiling data so the normal transaction can be sent. - $event->setSdkMetadata('profile', null); + if ($eventType->requiresRateLimiting()) { + if ($this->rateLimiter->isRateLimited((string)$eventType)) { $this->logger->warning( - 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + \sprintf('Rate limit exceeded for sending requests of type "%s".', (string)$eventType), ['event' => $event] ); + + return new Result(ResultStatus::rateLimit()); + } + + // Since profiles are attached to transaction we have to check separately if they are rate limited. + // We can do this after transactions have been checked because if transactions are rate limited, + // so are profiles but not the other way around. + if ($event->getSdkMetadata('profile') !== null) { + if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { + // Just remove profiling data so the normal transaction can be sent. + $event->setSdkMetadata('profile', null); + $this->logger->warning( + 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + ['event' => $event] + ); + } } } diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index e7455eb052..41e3968ec7 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -9,7 +9,7 @@ use Sentry\CheckIn; use Sentry\CheckInStatus; use Sentry\Client; -use Sentry\ClientReport\ClientReport; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Event; @@ -488,8 +488,8 @@ public static function serializeAsEnvelopeDataProvider(): iterable $event = Event::createClientReport(); $event->setClientReports([ - new ClientReport('log_item', 'buffer_overflow', 1), - new ClientReport('log_byte', 'buffer_overflow', 256), + new DiscardedEvent('log_item', 'buffer_overflow', 1), + new DiscardedEvent('log_byte', 'buffer_overflow', 256), ]); yield [ @@ -504,8 +504,8 @@ public static function serializeAsEnvelopeDataProvider(): iterable $event = Event::createClientReport(); $event->setClientReports([ - new ClientReport('error', 'before_send', 10), - new ClientReport('profile', 'internal_sdk_error', 50), + new DiscardedEvent('error', 'before_send', 10), + new DiscardedEvent('profile', 'internal_sdk_error', 50), ]); yield [ From afdf99dcd2bc107d1c38166f6d86dbcadcc6695c Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 9 Mar 2026 15:08:02 +0100 Subject: [PATCH 16/17] CS --- src/EventType.php | 2 -- src/Transport/HttpTransport.php | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/EventType.php b/src/EventType.php index 462e9d6102..6da2f13fbb 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -88,8 +88,6 @@ public function requiresEventId(): bool /** * Returns false if rate limiting should not be applied. - * - * @return bool */ public function requiresRateLimiting(): bool { diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 02b4695fe3..189d7eed8b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -92,9 +92,9 @@ public function send(Event $event): Result $eventType = $event->getType(); if ($eventType->requiresRateLimiting()) { - if ($this->rateLimiter->isRateLimited((string)$eventType)) { + if ($this->rateLimiter->isRateLimited((string) $eventType)) { $this->logger->warning( - \sprintf('Rate limit exceeded for sending requests of type "%s".', (string)$eventType), + \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), ['event' => $event] ); From d0a4b39b8e200584815909a1ce25a3126bd063e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 14:49:40 +0000 Subject: [PATCH 17/17] Avoid updating lastEventId when flushing client reports Applied via @cursor push command --- src/ClientReport/ClientReportAggregator.php | 4 +++- tests/ClientReport/ClientReportAggregatorTest.php | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php index 9499187953..8045a51b2b 100644 --- a/src/ClientReport/ClientReportAggregator.php +++ b/src/ClientReport/ClientReportAggregator.php @@ -64,9 +64,11 @@ public function flush(): void $event = Event::createClientReport(); $event->setClientReports($reports); + $client = HubAdapter::getInstance()->getClient(); + // Reset the client reports only if we successfully sent an event. If it fails it // can be sent on the next flush, or it gets discarded anyway. - if (HubAdapter::getInstance()->captureEvent($event) !== null) { + if ($client !== null && $client->captureEvent($event) !== null) { $this->reports = []; } } diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index 5e598a5437..ce82c4d50a 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -66,6 +66,20 @@ public function testClientReportAggregation(): void $this->assertSame(40, $report->getQuantity()); } + public function testFlushDoesNotOverwriteLastEventId(): void + { + $hub = SentrySdk::getCurrentHub(); + $eventId = $hub->captureMessage('foo'); + + $this->assertNotNull($eventId); + $this->assertSame($eventId, $hub->getLastEventId()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertSame($eventId, $hub->getLastEventId()); + } + public function testNegativeQuantityDiscarded(): void { ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10);