From 0070bf41d22764dceb4b397308dd166774fe2f10 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 1 Dec 2025 12:54:16 +0100 Subject: [PATCH 1/4] 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(); } /** From 7875accb364a18b17ff0ca874b46185f660b2370 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 9 Dec 2025 16:26:45 +0100 Subject: [PATCH 2/4] 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 3b374dd20..84e00baf7 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 202efc586..0b580574e 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 3cb0db1fd..96b8e07da 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 c29f34d3f..3c66bf7d4 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 4518e5842..5d87b1b70 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 fdb6d5912..3204f5900 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 dbfde7026..907404f7d 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 8bcd74f77..12666ebd4 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 000000000..5be76bb0e --- /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 53f3235e8..f3a8c869d 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 3/4] 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 5be76bb0e..8a38077f2 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 4/4] 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 8a38077f2..9746d9bf5 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())); }