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 */