diff --git a/src/State/Hub.php b/src/State/Hub.php index e9132531e..2f4b947ed 100644 --- a/src/State/Hub.php +++ b/src/State/Hub.php @@ -16,10 +16,10 @@ use Sentry\MonitorConfig; use Sentry\NoOpClient; use Sentry\Severity; -use Sentry\Tracing\SamplingContext; use Sentry\Tracing\Span; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; +use Sentry\Tracing\TransactionSampler; /** * This class is a basic implementation of the {@see HubInterface} interface. @@ -237,103 +237,8 @@ public function getIntegration(string $className): ?IntegrationInterface public function startTransaction(TransactionContext $context, array $customSamplingContext = []): Transaction { $transaction = new Transaction($context, $this); - $options = $this->getClient()->getOptions(); - $logger = $options->getLoggerOrNullLogger(); - if (!$options->isTracingEnabled()) { - $transaction->setSampled(false); - - $logger->warning(\sprintf('Transaction [%s] was started but tracing is not enabled.', (string) $transaction->getTraceId()), ['context' => $context]); - - return $transaction; - } - - $samplingContext = SamplingContext::getDefault($context); - $samplingContext->setAdditionalContext($customSamplingContext); - - $sampleSource = 'context'; - $sampleRand = $context->getMetadata()->getSampleRand(); - - if ($transaction->getSampled() === null) { - $tracesSampler = $options->getTracesSampler(); - - if ($tracesSampler !== null) { - $sampleRate = $tracesSampler($samplingContext); - $sampleSource = 'config:traces_sampler'; - } else { - $parentSampleRate = $context->getMetadata()->getParentSamplingRate(); - if ($parentSampleRate !== null) { - $sampleRate = $parentSampleRate; - $sampleSource = 'parent:sample_rate'; - } else { - $sampleRate = $this->getSampleRate( - $samplingContext->getParentSampled(), - $options->getTracesSampleRate() ?? 0 - ); - $sampleSource = $samplingContext->getParentSampled() !== null ? 'parent:sampling_decision' : 'config:traces_sample_rate'; - } - } - - if (!$this->isValidSampleRate($sampleRate)) { - $transaction->setSampled(false); - - $logger->warning(\sprintf('Transaction [%s] was started but not sampled because sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); - - return $transaction; - } - - $transaction->getMetadata()->setSamplingRate($sampleRate); - - // Always overwrite the sample_rate in the DSC - $dynamicSamplingContext = $context->getMetadata()->getDynamicSamplingContext(); - if ($dynamicSamplingContext !== null) { - $dynamicSamplingContext->set('sample_rate', (string) $sampleRate, true); - } - - if ($sampleRate === 0.0) { - $transaction->setSampled(false); - - $logger->info(\sprintf('Transaction [%s] was started but not sampled because sample rate (decided by %s) is %s.', (string) $transaction->getTraceId(), $sampleSource, $sampleRate), ['context' => $context]); - - return $transaction; - } - - $transaction->setSampled($sampleRand < $sampleRate); - } - - if (!$transaction->getSampled()) { - $logger->info(\sprintf('Transaction [%s] was started but not sampled, decided by %s.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); - - return $transaction; - } - - $logger->info(\sprintf('Transaction [%s] was started and sampled, decided by %s.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); - - $transaction->initSpanRecorder(); - - $profilesSampleSource = 'config:profiles_sample_rate'; - $profilesSampler = $options->getProfilesSampler(); - - if ($profilesSampler !== null) { - $profilesSampleRate = $profilesSampler($samplingContext); - $profilesSampleSource = 'config:profiles_sampler'; - } else { - $profilesSampleRate = $options->getProfilesSampleRate(); - } - - if ($profilesSampleRate === null) { - $logger->info(\sprintf('Transaction [%s] is not profiling because neither `profiles_sample_rate` nor `profiles_sampler` option is set.', (string) $transaction->getTraceId())); - } elseif (!$this->isValidSampleRate($profilesSampleRate)) { - $logger->warning(\sprintf('Transaction [%s] is not profiling because profile sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $profilesSampleSource)); - } elseif ($this->sample($profilesSampleRate)) { - $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); - - $transaction->initProfiler()->start(); - } else { - $logger->info(\sprintf('Transaction [%s] is not profiling because it was not sampled.', (string) $transaction->getTraceId())); - } - - return $transaction; + return (new TransactionSampler($this->getClient()->getOptions()))->startTransaction($transaction, $context, $customSamplingContext); } /** @@ -377,49 +282,4 @@ private function getStackTop(): Layer { return $this->stack[\count($this->stack) - 1]; } - - private function getSampleRate(?bool $hasParentBeenSampled, float $fallbackSampleRate): float - { - if ($hasParentBeenSampled === true) { - return 1.0; - } - - if ($hasParentBeenSampled === false) { - return 0.0; - } - - return $fallbackSampleRate; - } - - /** - * @param mixed $sampleRate - */ - private function sample($sampleRate): bool - { - if ($sampleRate === 0.0 || $sampleRate === null) { - return false; - } - - if ($sampleRate === 1.0) { - return true; - } - - return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax() < $sampleRate; - } - - /** - * @param mixed $sampleRate - */ - private function isValidSampleRate($sampleRate): bool - { - if (!\is_float($sampleRate) && !\is_int($sampleRate)) { - return false; - } - - if ($sampleRate < 0 || $sampleRate > 1) { - return false; - } - - return true; - } } diff --git a/src/Tracing/TransactionSampler.php b/src/Tracing/TransactionSampler.php new file mode 100644 index 000000000..c3bd46b9f --- /dev/null +++ b/src/Tracing/TransactionSampler.php @@ -0,0 +1,177 @@ +options = $options; + } + + /** + * @param array $customSamplingContext Additional context that will be passed to the {@see SamplingContext} + */ + public function startTransaction(Transaction $transaction, TransactionContext $context, array $customSamplingContext = []): Transaction + { + $logger = $this->options->getLoggerOrNullLogger(); + + if (!$this->options->isTracingEnabled()) { + $transaction->setSampled(false); + + $logger->warning(\sprintf('Transaction [%s] was started but tracing is not enabled.', (string) $transaction->getTraceId()), ['context' => $context]); + + return $transaction; + } + + $samplingContext = SamplingContext::getDefault($context); + $samplingContext->setAdditionalContext($customSamplingContext); + + $sampleSource = 'context'; + $sampleRand = $context->getMetadata()->getSampleRand() ?? 0.0; + + if ($transaction->getSampled() === null) { + $tracesSampler = $this->options->getTracesSampler(); + + if ($tracesSampler !== null) { + $sampleRate = $tracesSampler($samplingContext); + $sampleSource = 'config:traces_sampler'; + } else { + $parentSampleRate = $context->getMetadata()->getParentSamplingRate(); + if ($parentSampleRate !== null) { + $sampleRate = $parentSampleRate; + $sampleSource = 'parent:sample_rate'; + } else { + $sampleRate = $this->getSampleRate( + $samplingContext->getParentSampled(), + $this->options->getTracesSampleRate() ?? 0 + ); + $sampleSource = $samplingContext->getParentSampled() !== null ? 'parent:sampling_decision' : 'config:traces_sample_rate'; + } + } + + if (!$this->isValidSampleRate($sampleRate)) { + $transaction->setSampled(false); + + $logger->warning(\sprintf('Transaction [%s] was started but not sampled because sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); + + return $transaction; + } + + $transaction->getMetadata()->setSamplingRate($sampleRate); + + // Always overwrite the sample_rate in the DSC + $dynamicSamplingContext = $context->getMetadata()->getDynamicSamplingContext(); + if ($dynamicSamplingContext !== null) { + $dynamicSamplingContext->set('sample_rate', (string) $sampleRate, true); + } + + if ($sampleRate === 0.0) { + $transaction->setSampled(false); + + $logger->info(\sprintf('Transaction [%s] was started but not sampled because sample rate (decided by %s) is %s.', (string) $transaction->getTraceId(), $sampleSource, $sampleRate), ['context' => $context]); + + return $transaction; + } + + $transaction->setSampled($sampleRand < $sampleRate); + } + + if (!$transaction->getSampled()) { + $logger->info(\sprintf('Transaction [%s] was started but not sampled, decided by %s.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); + + return $transaction; + } + + $logger->info(\sprintf('Transaction [%s] was started and sampled, decided by %s.', (string) $transaction->getTraceId(), $sampleSource), ['context' => $context]); + + $transaction->initSpanRecorder(); + + $profilesSampleSource = 'config:profiles_sample_rate'; + $profilesSampler = $this->options->getProfilesSampler(); + + if ($profilesSampler !== null) { + $profilesSampleRate = $profilesSampler($samplingContext); + $profilesSampleSource = 'config:profiles_sampler'; + } else { + $profilesSampleRate = $this->options->getProfilesSampleRate(); + } + + if ($profilesSampleRate === null) { + $logger->info(\sprintf('Transaction [%s] is not profiling because neither `profiles_sample_rate` nor `profiles_sampler` option is set.', (string) $transaction->getTraceId())); + } elseif (!$this->isValidSampleRate($profilesSampleRate)) { + $logger->warning(\sprintf('Transaction [%s] is not profiling because profile sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $profilesSampleSource)); + } elseif ($this->sampleRate($profilesSampleRate)) { + $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); + + $transaction->initProfiler()->start(); + } else { + $logger->info(\sprintf('Transaction [%s] is not profiling because it was not sampled.', (string) $transaction->getTraceId())); + } + + return $transaction; + } + + private function getSampleRate(?bool $hasParentBeenSampled, float $fallbackSampleRate): float + { + if ($hasParentBeenSampled === true) { + return 1.0; + } + + if ($hasParentBeenSampled === false) { + return 0.0; + } + + return $fallbackSampleRate; + } + + /** + * @param mixed $sampleRate + */ + private function sampleRate($sampleRate): bool + { + if (!\is_float($sampleRate) && !\is_int($sampleRate)) { + return false; + } + + if ($sampleRate === 0.0) { + return false; + } + + if ($sampleRate === 1.0) { + return true; + } + + return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax() < (float) $sampleRate; + } + + /** + * @param mixed $sampleRate + */ + private function isValidSampleRate($sampleRate): bool + { + if (!\is_float($sampleRate) && !\is_int($sampleRate)) { + return false; + } + + if ($sampleRate < 0 || $sampleRate > 1) { + return false; + } + + return true; + } +} diff --git a/tests/Tracing/TransactionSamplerTest.php b/tests/Tracing/TransactionSamplerTest.php new file mode 100644 index 000000000..1fe3aab72 --- /dev/null +++ b/tests/Tracing/TransactionSamplerTest.php @@ -0,0 +1,330 @@ +sampleTransaction($options, $transactionContext); + + $this->assertSame($expectedSampled, $transaction->getSampled()); + } + + public function testIgnoresBaggageSampleRateWithoutSentryTrace(): void + { + $transactionContext = TransactionContext::fromHeaders('', 'sentry-sample_rate=1'); + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 0.0, + ]), $transactionContext); + + $this->assertFalse($transaction->getSampled()); + } + + public static function sampleTransactionDataProvider(): iterable + { + yield 'Acceptable float value returned from traces_sampler' => [ + new Options([ + 'traces_sampler' => static function (): float { + return 1.0; + }, + ]), + new TransactionContext(), + true, + ]; + + yield 'Acceptable but too low float value returned from traces_sampler' => [ + new Options([ + 'traces_sampler' => static function (): float { + return 0.0; + }, + ]), + new TransactionContext(), + false, + ]; + + yield 'Acceptable integer value returned from traces_sampler' => [ + new Options([ + 'traces_sampler' => static function (): int { + return 1; + }, + ]), + new TransactionContext(), + true, + ]; + + yield 'Acceptable but too low integer value returned from traces_sampler' => [ + new Options([ + 'traces_sampler' => static function (): int { + return 0; + }, + ]), + new TransactionContext(), + false, + ]; + + yield 'Acceptable float value returned from traces_sample_rate' => [ + new Options([ + 'traces_sample_rate' => 1.0, + ]), + new TransactionContext(), + true, + ]; + + yield 'Acceptable but too low float value returned from traces_sample_rate' => [ + new Options([ + 'traces_sample_rate' => 0.0, + ]), + new TransactionContext(), + false, + ]; + + yield 'Acceptable integer value returned from traces_sample_rate' => [ + new Options([ + 'traces_sample_rate' => 1, + ]), + new TransactionContext(), + true, + ]; + + yield 'Acceptable but too low integer value returned from traces_sample_rate' => [ + new Options([ + 'traces_sample_rate' => 0, + ]), + new TransactionContext(), + false, + ]; + + yield 'Acceptable but too low value returned from traces_sample_rate which is preferred over sample_rate' => [ + new Options([ + 'sample_rate' => 1.0, + 'traces_sample_rate' => 0.0, + ]), + new TransactionContext(), + false, + ]; + + yield 'Acceptable value returned from traces_sample_rate which is preferred over sample_rate' => [ + new Options([ + 'sample_rate' => 0.0, + 'traces_sample_rate' => 1.0, + ]), + new TransactionContext(), + true, + ]; + + yield 'Acceptable value returned from SamplingContext::getParentSampled() which is preferred over traces_sample_rate (x1)' => [ + new Options([ + 'traces_sample_rate' => 0.5, + ]), + new TransactionContext(TransactionContext::DEFAULT_NAME, true), + true, + ]; + + yield 'Acceptable value returned from SamplingContext::getParentSampled() which is preferred over traces_sample_rate (x2)' => [ + new Options([ + 'traces_sample_rate' => 1.0, + ]), + new TransactionContext(TransactionContext::DEFAULT_NAME, false), + false, + ]; + + yield 'Invalid incoming sample_rand is ignored' => [ + new Options([ + 'traces_sample_rate' => 1.0, + ]), + TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', + 'sentry-sample_rand=2.0' + ), + true, + ]; + + yield 'Out of range sample rate returned from traces_sampler (lower than minimum)' => [ + new Options([ + 'traces_sampler' => static function (): float { + return -1.0; + }, + ]), + new TransactionContext(TransactionContext::DEFAULT_NAME, false), + false, + ]; + + yield 'Out of range sample rate returned from traces_sampler (greater than maximum)' => [ + new Options([ + 'traces_sampler' => static function (): float { + return 1.1; + }, + ]), + new TransactionContext(TransactionContext::DEFAULT_NAME, false), + false, + ]; + + yield 'Invalid type returned from traces_sampler' => [ + new Options([ + 'traces_sampler' => static function (): string { + return 'foo'; + }, + ]), + new TransactionContext(TransactionContext::DEFAULT_NAME, false), + false, + ]; + } + + public function testDoesNothingIfTracingIsNotEnabled(): void + { + $transaction = $this->sampleTransaction(new Options(), new TransactionContext()); + + $this->assertFalse($transaction->getSampled()); + } + + public function testPassesCustomSamplingContextToTracesSampler(): void + { + $customSamplingContext = ['a' => 'b']; + $samplerInvoked = false; + + $this->sampleTransaction(new Options([ + 'traces_sampler' => function (SamplingContext $samplingContext) use ($customSamplingContext, &$samplerInvoked): float { + $this->assertSame($customSamplingContext, $samplingContext->getAdditionalContext()); + $samplerInvoked = true; + + return 1.0; + }, + ]), new TransactionContext(), $customSamplingContext); + + $this->assertTrue($samplerInvoked); + } + + public function testStartsProfilerWithProfilesSampler(): void + { + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 1.0; + }, + ]), new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNotNull($transaction->getProfiler()); + } + + public function testDoesNotStartProfilerWhenProfilesSamplerReturnsZero(): void + { + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ]), new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testPrefersProfilesSamplerOverProfilesSampleRate(): void + { + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ]), new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testPassesCustomSamplingContextToProfilesSampler(): void + { + $customSamplingContext = ['a' => 'b']; + $samplerInvoked = false; + + $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => function (SamplingContext $samplingContext) use ($customSamplingContext, &$samplerInvoked): float { + $this->assertSame($customSamplingContext, $samplingContext->getAdditionalContext()); + $samplerInvoked = true; + + return 0.0; + }, + ]), new TransactionContext(), $customSamplingContext); + + $this->assertTrue($samplerInvoked); + } + + public function testDoesNotStartProfilerWhenProfilesSamplerReturnsInvalidValue(): void + { + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): string { + return 'foo'; + }, + ]), new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testDoesNotCallProfilesSamplerWhenTransactionIsNotSampled(): void + { + $profilesSamplerInvoked = false; + + $transaction = $this->sampleTransaction(new Options([ + 'traces_sample_rate' => 0.0, + 'profiles_sampler' => static function () use (&$profilesSamplerInvoked): float { + $profilesSamplerInvoked = true; + + return 1.0; + }, + ]), new TransactionContext()); + + $this->assertFalse($transaction->getSampled()); + $this->assertFalse($profilesSamplerInvoked); + $this->assertNull($transaction->getProfiler()); + } + + public function testUpdatesTheDscSampleRate(): void + { + $dsc = DynamicSamplingContext::fromHeader('sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-public_key=public'); + $transactionMetaData = new TransactionMetadata(null, $dsc); + $transactionContext = new TransactionContext(TransactionContext::DEFAULT_NAME, null, $transactionMetaData); + + $transaction = $this->sampleTransaction(new Options([ + 'traces_sampler' => static function (SamplingContext $samplingContext): float { + return 1.0; + }, + ]), $transactionContext); + + $this->assertSame('1', $transaction->getMetadata()->getDynamicSamplingContext()->get('sample_rate')); + } + + /** + * @param array $customSamplingContext + */ + private function sampleTransaction(Options $options, TransactionContext $transactionContext, array $customSamplingContext = []): Transaction + { + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions')->willReturn($options); + + $transaction = new Transaction($transactionContext, new Hub($client)); + + return (new TransactionSampler($options))->startTransaction($transaction, $transactionContext, $customSamplingContext); + } +}