diff --git a/src/Client.php b/src/Client.php index e54cb3d29..84e00baf7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -178,7 +178,10 @@ 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); + // Client reports don't need to be augmented in the prepareEvent pipeline. + 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..0b580574e --- /dev/null +++ b/src/ClientReport/ClientReport.php @@ -0,0 +1,45 @@ +category = $category; + $this->reason = $reason; + $this->quantity = $quantity; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + 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..96b8e07da --- /dev/null +++ b/src/ClientReport/ClientReportAggregator.php @@ -0,0 +1,79 @@ + [ + * 'example-reason' => 10 + * ] + * ] + *``` + * + * @var array> + */ + private $reports = []; + + public function add(DataCategory $category, Reason $reason, int $quantity): void + { + $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) { + $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..3c66bf7d4 --- /dev/null +++ b/src/ClientReport/Reason.php @@ -0,0 +1,102 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->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'); + } + + 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 e2d2a8c0f..334c34b6a 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; @@ -210,6 +211,11 @@ final class Event */ private $profile; + /** + * @var ClientReport[] + */ + private $clientReports; + private function __construct(?EventId $eventId, EventType $eventType) { $this->id = $eventId ?? EventId::generate(); @@ -252,6 +258,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. */ @@ -978,4 +989,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 679f96633..5c068d0f1 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -52,6 +52,11 @@ public static function metrics(): self return self::getInstance('trace_metric'); } + public static function clientReport(): self + { + return self::getInstance('client_report'); + } + /** * List of all cases on the enum. * @@ -65,6 +70,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..5d87b1b70 --- /dev/null +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -0,0 +1,30 @@ +getClientReports(); + + $headers = ['type' => 'client_report']; + $body = [ + 'timestamp' => $event->getTimestamp(), + '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 45de0f29f..3204f5900 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\MetricsItem; @@ -39,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; + } } } @@ -80,6 +84,13 @@ public function serialize(Event $event): string case EventType::metrics(): $items[] = MetricsItem::toEnvelopeItem($event); break; + case EventType::clientReport(): + $items[] = ClientReportItem::toEnvelopeItem($event); + break; + } + + 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 new file mode 100644 index 000000000..907404f7d --- /dev/null +++ b/src/Transport/DataCategory.php @@ -0,0 +1,83 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->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 static function internal(): self + { + return self::getInstance('internal'); + } + + 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/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php new file mode 100644 index 000000000..9746d9bf5 --- /dev/null +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -0,0 +1,87 @@ +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->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]); + } + + 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, + ]; + } +}