From cc5bb99b16d1241e7fb020104eb093383545920e Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 2 Apr 2026 17:39:18 +0200 Subject: [PATCH 1/2] ref(logs): use RingBuffer for logs when not using `log_flush_threshold` --- src/Logs/LogsAggregator.php | 39 +++++++--- src/Util/TelemetryStorage.php | 109 ++++++++++++++++++++++++++++ tests/Util/TelemetryStorageTest.php | 78 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 src/Util/TelemetryStorage.php create mode 100644 tests/Util/TelemetryStorageTest.php diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 9d19b08a1..bc0b5f302 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -13,16 +13,19 @@ use Sentry\State\Scope; use Sentry\Util\Arr; use Sentry\Util\Str; +use Sentry\Util\TelemetryStorage; /** * @internal */ final class LogsAggregator { + private const LOGS_BUFFER_SIZE = 1000; + /** - * @var Log[] + * @var TelemetryStorage|null */ - private $logs = []; + private $logs; /** * @param string $message see sprintf for a description of format @@ -155,25 +158,24 @@ public function add( $sdkLogger->log($log->getPsrLevel(), "Logs item: {$log->getBody()}", $log->attributes()->toSimpleArray()); } - $this->logs[] = $log; - $logFlushThreshold = $options->getLogFlushThreshold(); + $logs = $this->getStorage($logFlushThreshold); - if ($logFlushThreshold !== null && \count($this->logs) >= $logFlushThreshold) { + $logs->push($log); + + if ($logFlushThreshold !== null && \count($logs) >= $logFlushThreshold) { $this->flush($hub); } } public function flush(?HubInterface $hub = null): ?EventId { - if (empty($this->logs)) { + if ($this->logs === null || $this->logs->isEmpty()) { return null; } $hub = $hub ?? SentrySdk::getCurrentHub(); - $event = Event::createLogs()->setLogs($this->logs); - - $this->logs = []; + $event = Event::createLogs()->setLogs($this->logs->drain()); return $hub->captureEvent($event); } @@ -183,7 +185,7 @@ public function flush(?HubInterface $hub = null): ?EventId */ public function all(): array { - return $this->logs; + return $this->logs !== null ? $this->logs->toArray() : []; } /** @@ -223,4 +225,21 @@ private function getTraceData(HubInterface $hub): array /** @var array{trace_id: string, parent_span_id: string|null} $traceData */ return $traceData; } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $logFlushThreshold = null): TelemetryStorage + { + if ($this->logs === null) { + /** @var TelemetryStorage $logs */ + $logs = $logFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::LOGS_BUFFER_SIZE); + + $this->logs = $logs; + } + + return $this->logs; + } } diff --git a/src/Util/TelemetryStorage.php b/src/Util/TelemetryStorage.php new file mode 100644 index 000000000..228949330 --- /dev/null +++ b/src/Util/TelemetryStorage.php @@ -0,0 +1,109 @@ + + */ + private $data; + + private function __construct(?int $size = null) + { + if ($size !== null) { + $this->data = new RingBuffer($size); + } else { + $this->data = []; + } + } + + public function count(): int + { + return \count($this->data); + } + + /** + * @param T $value + */ + public function push($value): void + { + if ($this->data instanceof RingBuffer) { + $this->data->push($value); + } else { + $this->data[] = $value; + } + } + + /** + * @return T[] + */ + public function drain(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->drain(); + } + $data = $this->data; + $this->data = []; + + return $data; + } + + /** + * @return T[] + */ + public function toArray(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->toArray(); + } + + return $this->data; + } + + public function isEmpty(): bool + { + if ($this->data instanceof RingBuffer) { + return $this->data->isEmpty(); + } + + return empty($this->data); + } + + /** + * Creates a new TelemetryStorage that is not bounded in size. This version should only be used if there + * is another flushing signal available. + * + * @return self + */ + public static function unbounded(): self + { + return new self(); + } + + /** + * Creates a TelemetryStorage that has an upper bound of $size. It will drop the oldest items when new items + * are added while being at capacity. + * + * @return self + */ + public static function bounded(int $size): self + { + return new self($size); + } +} diff --git a/tests/Util/TelemetryStorageTest.php b/tests/Util/TelemetryStorageTest.php new file mode 100644 index 000000000..82571e89b --- /dev/null +++ b/tests/Util/TelemetryStorageTest.php @@ -0,0 +1,78 @@ +push('foo'); + $storage->push('bar'); + + $result = $storage->toArray(); + $this->assertSame(2, $storage->count()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedDrainClearsStorage(): void + { + $storage = TelemetryStorage::unbounded(); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedIsEmpty(): void + { + $storage = TelemetryStorage::unbounded(); + $this->assertTrue($storage->isEmpty()); + + $storage->push('foo'); + + $this->assertFalse($storage->isEmpty()); + } + + public function testBoundedCapacityOverwritesOldestItems(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $this->assertEquals(['bar', 'baz'], $storage->toArray()); + } + + public function testBoundedDrainReturnsLogicalOrderAndClearsStorage(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['bar', 'baz'], $result); + } + + public function testBoundedCapacityOneKeepsLatestItem(): void + { + $storage = TelemetryStorage::bounded(1); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertCount(1, $storage); + $this->assertEquals(['bar'], $storage->toArray()); + } +} From 713e4a05bd2d0adbf3dc6447dd4318fc35ff1d66 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 2 Apr 2026 18:04:14 +0200 Subject: [PATCH 2/2] feat(metrics): add `metric_flush_threshold` flag --- src/Metrics/MetricsAggregator.php | 54 +++++++++++++++++++++--------- src/Options.php | 39 +++++++++++++++++++++ src/functions.php | 1 + tests/Metrics/TraceMetricsTest.php | 46 ++++++++++++++++++++++++- tests/OptionsTest.php | 41 +++++++++++++++++++++++ 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 47bfcb64d..6f763e1dd 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -17,7 +17,7 @@ use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; use Sentry\Unit; -use Sentry\Util\RingBuffer; +use Sentry\Util\TelemetryStorage; /** * @internal @@ -29,22 +29,17 @@ final class MetricsAggregator */ public const METRICS_BUFFER_SIZE = 1000; - /** - * @var RingBuffer - */ - private $metrics; - - public function __construct() - { - $this->metrics = new RingBuffer(self::METRICS_BUFFER_SIZE); - } - private const METRIC_TYPES = [ CounterMetric::TYPE => CounterMetric::class, DistributionMetric::TYPE => DistributionMetric::class, GaugeMetric::TYPE => GaugeMetric::class, ]; + /** + * @var TelemetryStorage|null + */ + private $metrics; + /** * @param int|float $value * @param array $attributes @@ -58,6 +53,7 @@ public function add( ): void { $hub = SentrySdk::getCurrentHub(); $client = $hub->getClient(); + $metricFlushThreshold = null; if (!\is_int($value) && !\is_float($value)) { if ($client !== null) { @@ -67,20 +63,24 @@ public function add( return; } - if ($client instanceof Client) { + if ($client !== null) { $options = $client->getOptions(); + $metricFlushThreshold = $options->getMetricFlushThreshold(); if ($options->getEnableMetrics() === false) { return; } $defaultAttributes = [ - 'sentry.sdk.name' => $client->getSdkIdentifier(), - 'sentry.sdk.version' => $client->getSdkVersion(), 'sentry.environment' => $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT, 'server.address' => $options->getServerName(), ]; + if ($client instanceof Client) { + $defaultAttributes['sentry.sdk.name'] = $client->getSdkIdentifier(); + $defaultAttributes['sentry.sdk.version'] = $client->getSdkVersion(); + } + if ($options->shouldSendDefaultPii()) { $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { $user = $scope->getUser(); @@ -122,12 +122,17 @@ public function add( } } - $this->metrics->push($metric); + $metrics = $this->getStorage($metricFlushThreshold); + $metrics->push($metric); + + if ($metricFlushThreshold !== null && \count($metrics) >= $metricFlushThreshold) { + $this->flush($hub); + } } public function flush(?HubInterface $hub = null): ?EventId { - if ($this->metrics->isEmpty()) { + if ($this->metrics === null || $this->metrics->isEmpty()) { return null; } @@ -151,4 +156,21 @@ private function getTraceContext(HubInterface $hub): array /** @var array{trace_id: string, span_id: string} $traceContext */ return $traceContext; } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $metricFlushThreshold = null): TelemetryStorage + { + if ($this->metrics === null) { + /** @var TelemetryStorage $metrics */ + $metrics = $metricFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::METRICS_BUFFER_SIZE); + + $this->metrics = $metrics; + } + + return $this->metrics; + } } diff --git a/src/Options.php b/src/Options.php index 853d47919..c6398433d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -210,6 +210,32 @@ public function setLogFlushThreshold(?int $logFlushThreshold): self return $this; } + /** + * Gets the number of buffered metrics that trigger an immediate flush. + */ + public function getMetricFlushThreshold(): ?int + { + /** + * @var int|null $metricFlushThreshold + */ + $metricFlushThreshold = $this->options['metric_flush_threshold']; + + return $metricFlushThreshold; + } + + /** + * Sets the number of buffered metrics that trigger an immediate flush. + * null will never trigger an immediate flush. + */ + public function setMetricFlushThreshold(?int $metricFlushThreshold): self + { + $options = array_merge($this->options, ['metric_flush_threshold' => $metricFlushThreshold]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Sets if metrics should be enabled or not. */ @@ -1365,6 +1391,7 @@ private function configureOptions(OptionsResolver $resolver): void 'enable_logs' => false, 'log_flush_threshold' => null, 'enable_metrics' => true, + 'metric_flush_threshold' => null, 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, @@ -1443,6 +1470,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('enable_logs', 'bool'); $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('enable_metrics', 'bool'); + $resolver->setAllowedTypes('metric_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); @@ -1496,6 +1524,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); + $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); @@ -1671,4 +1700,14 @@ private function validateLogFlushThresholdOption(?int $logFlushThreshold): bool { return $logFlushThreshold === null || $logFlushThreshold > 0; } + + /** + * Validates that the value passed to the "metric_flush_threshold" option is valid. + * + * @param int|null $metricFlushThreshold The value to validate + */ + private function validateMetricFlushThresholdOption(?int $metricFlushThreshold): bool + { + return $metricFlushThreshold === null || $metricFlushThreshold > 0; + } } diff --git a/src/functions.php b/src/functions.php index f8ae0be4f..44f5b2d0b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -51,6 +51,7 @@ * integrations?: IntegrationInterface[]|callable(IntegrationInterface[]): IntegrationInterface[], * logger?: LoggerInterface|null, * log_flush_threshold?: int|null, + * metric_flush_threshold?: int|null, * max_breadcrumbs?: int, * max_request_body_size?: "none"|"never"|"small"|"medium"|"always", * max_value_length?: int, diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 0faab3a2f..223e2ee5c 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -73,15 +73,59 @@ public function testDistributionMetrics(): void $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); } - public function testMetricsBufferFull(): void + public function testFlushesImmediatelyWhenMetricFlushThresholdIsReached(): void { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => 2, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(2, $event->getMetrics()); + $this->assertSame('first-metric', $event->getMetrics()[0]->getName()); + $this->assertSame('second-metric', $event->getMetrics()[1]->getName()); + } + + public function testDoesNotFlushImmediatelyWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $this->assertCount(2, StubTransport::$events[0]->getMetrics()); + } + + public function testMetricsBufferFullWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { traceMetrics()->count('test', 1, ['foo' => 'bar']); } + traceMetrics()->flush(); + $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); + $this->assertCount(MetricsAggregator::METRICS_BUFFER_SIZE, $metrics); } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 9ae04194b..4606978c6 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -114,6 +114,20 @@ public static function optionsDataProvider(): \Generator 'setLogFlushThreshold', ]; + yield [ + 'metric_flush_threshold', + 10, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + + yield [ + 'metric_flush_threshold', + null, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + yield [ 'traces_sample_rate', 0.5, @@ -682,6 +696,33 @@ public static function logFlushThresholdOptionIsValidatedCorrectlyDataProvider() ]; } + /** + * @dataProvider metricFlushThresholdOptionIsValidatedCorrectlyDataProvider + */ + public function testMetricFlushThresholdOptionIsValidatedCorrectly(bool $isValid, $value): void + { + if (!$isValid) { + $this->expectException(InvalidOptionsException::class); + } + + $options = new Options(['metric_flush_threshold' => $value]); + + $this->assertSame($value, $options->getMetricFlushThreshold()); + } + + public static function metricFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array + { + return [ + [false, -1], + [false, 0], + [true, 1], + [true, 10], + [true, null], + [false, 'string'], + [false, '1'], + ]; + } + /** * @backupGlobals enabled */