From 5dd77b52a8b629d0e61c8729db4f7eb9e01de7d2 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 14 Oct 2026 15:36:59 +0200 Subject: [PATCH] feat(profiling): stop profiler on context end --- src/Profiling/ContinuousProfiler.php | 24 +++++- src/State/RuntimeContextManager.php | 3 + tests/SentrySdkTest.php | 116 ++++++++++++++++++++++++++- tests/StubRuntimeContextStorage.php | 20 ++++- 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/Profiling/ContinuousProfiler.php b/src/Profiling/ContinuousProfiler.php index 0c81a53d3..850adeb63 100644 --- a/src/Profiling/ContinuousProfiler.php +++ b/src/Profiling/ContinuousProfiler.php @@ -55,7 +55,7 @@ final class ContinuousProfiler private $sampled; /** - * @var array The span IDs of the sampled root spans the profiler is running for + * @var array The runtime context IDs of the sampled root spans the profiler is running for, keyed by span ID */ private $activeRootSpans = []; @@ -145,7 +145,7 @@ public function startForRootSpan(string $spanId): void $this->start(); if ($this->profiler !== null) { - $this->activeRootSpans[$spanId] = true; + $this->activeRootSpans[$spanId] = SentrySdk::getCurrentRuntimeContext()->getId(); } } @@ -162,6 +162,26 @@ public function stopForRootSpan(string $spanId): void } } + /** + * Releases the root spans of an ending runtime context that were never finished. + */ + public function stopForRuntimeContext(string $runtimeContextId): void + { + $released = false; + + foreach ($this->activeRootSpans as $spanId => $rootSpanRuntimeContextId) { + if ($rootSpanRuntimeContextId === $runtimeContextId) { + unset($this->activeRootSpans[$spanId]); + + $released = true; + } + } + + if ($released && $this->activeRootSpans === []) { + $this->stop(); + } + } + public function stop(): void { if ($this->profiler === null) { diff --git a/src/State/RuntimeContextManager.php b/src/State/RuntimeContextManager.php index db39364fd..02728ae7c 100644 --- a/src/State/RuntimeContextManager.php +++ b/src/State/RuntimeContextManager.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Sentry\ErrorHandler; +use Sentry\Profiling\Profiles; use Sentry\Tracing\PropagationContext; /** @@ -122,6 +123,8 @@ public function endContext(?int $timeout = null): void return; } + Profiles::getInstance()->continuousProfiler()->stopForRuntimeContext($runtimeContext->getId()); + $logger = $this->getLoggerFromHub($runtimeContext->getHub()); $this->flushRuntimeContextResources($runtimeContext, $timeout, $logger); diff --git a/tests/SentrySdkTest.php b/tests/SentrySdkTest.php index ebe2f75ad..642626b40 100644 --- a/tests/SentrySdkTest.php +++ b/tests/SentrySdkTest.php @@ -18,6 +18,7 @@ use Sentry\State\Scope; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; +use Sentry\Tracing\TransactionContext; use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; @@ -140,7 +141,7 @@ public function testWithContextResetsSpanAndTransactionAcrossInvocations(): void SentrySdk::init(); SentrySdk::withContext(function (): void { - $transaction = SentrySdk::getCurrentHub()->startTransaction(new \Sentry\Tracing\TransactionContext('request-1')); + $transaction = SentrySdk::getCurrentHub()->startTransaction(new TransactionContext('request-1')); SentrySdk::getCurrentHub()->setSpan($transaction); $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); @@ -442,6 +443,110 @@ public function testFlushSendsCollectedProfileSamplesAndKeepsProfiling(): void $this->assertCount(2, StubTransport::$events); } + /** + * @requires extension excimer + */ + public function testEndContextStopsProfilerForUnfinishedRootSpans(): void + { + StubTransport::$events = []; + + self::initWithProfileLifecycleTrace(); + + $profiler = Profiles::getInstance()->continuousProfiler(); + + SentrySdk::startContext(); + SentrySdk::getCurrentHub()->startTransaction(TransactionContext::make()); + + $this->assertTrue($profiler->isRunning()); + + self::busyWait(0.05); + + SentrySdk::endContext(); + + $this->assertFalse($profiler->isRunning()); + $this->assertCount(1, StubTransport::$events); + $this->assertNotNull(StubTransport::$events[0]->getProfileChunk()); + } + + /** + * @requires extension excimer + */ + public function testEndContextKeepsProfilerRunningForRootSpansOfOtherContexts(): void + { + self::initWithProfileLifecycleTrace(); + + $profiler = Profiles::getInstance()->continuousProfiler(); + $transaction = SentrySdk::getCurrentHub()->startTransaction(TransactionContext::make()); + + SentrySdk::startContext(); + SentrySdk::getCurrentHub()->startTransaction(TransactionContext::make()); + SentrySdk::endContext(); + + $this->assertTrue($profiler->isRunning()); + + $transaction->finish(); + + $this->assertFalse($profiler->isRunning()); + } + + /** + * @requires extension excimer + */ + public function testEndContextKeepsProfilerRunningWhileAnotherExecutionSharesTheContext(): void + { + $storage = new StubRuntimeContextStorage(); + SentrySdk::setRuntimeContextStorage($storage); + + self::initWithProfileLifecycleTrace(); + + $profiler = Profiles::getInstance()->continuousProfiler(); + + SentrySdk::startContext(); + SentrySdk::getCurrentHub()->startTransaction(TransactionContext::make()); + + // A child coroutine of the request shares its context and ends first + $storage->share('child'); + $storage->switchTo('child'); + + SentrySdk::endContext(); + + $this->assertTrue($profiler->isRunning()); + + $storage->switchTo('default'); + + SentrySdk::endContext(); + + $this->assertFalse($profiler->isRunning()); + } + + /** + * @requires extension excimer + */ + public function testWithContextKeepsProfilerRunningWhileAnotherExecutionSharesTheContext(): void + { + $storage = new StubRuntimeContextStorage(); + SentrySdk::setRuntimeContextStorage($storage); + + self::initWithProfileLifecycleTrace(); + + $profiler = Profiles::getInstance()->continuousProfiler(); + + SentrySdk::withContext(static function () use ($storage): void { + SentrySdk::getCurrentHub()->startTransaction(TransactionContext::make()); + + // A detached child coroutine shares the context and outlives the callback + $storage->share('child'); + }); + + $this->assertTrue($profiler->isRunning()); + + $storage->switchTo('child'); + + SentrySdk::endContext(); + + $this->assertFalse($profiler->isRunning()); + } + public function testFlushFlushesClientTransport(): void { /** @var ClientInterface&MockObject $client */ @@ -602,6 +707,15 @@ private function getCurrentScopeTag(string $key): ?string return $value; } + private static function initWithProfileLifecycleTrace(): void + { + SentrySdk::init()->bindClient(new Client(new Options([ + 'traces_sample_rate' => 1.0, + 'profile_lifecycle' => 'trace', + 'profile_session_sample_rate' => 1.0, + ]), StubTransport::getInstance())); + } + private static function busyWait(float $seconds): void { $end = microtime(true) + $seconds; diff --git a/tests/StubRuntimeContextStorage.php b/tests/StubRuntimeContextStorage.php index d3041e15f..ef927ace3 100644 --- a/tests/StubRuntimeContextStorage.php +++ b/tests/StubRuntimeContextStorage.php @@ -8,7 +8,8 @@ use Sentry\State\RuntimeContextStorageInterface; /** - * Models independently selectable execution slots and abandoned execution cleanup. + * Models independently selectable execution slots, contexts shared between executions and + * abandoned execution cleanup. */ final class StubRuntimeContextStorage implements RuntimeContextStorageInterface { @@ -37,6 +38,11 @@ public function remove(): ?RuntimeContext $runtimeContext = $this->get(); unset($this->contexts[$this->execution]); + // A shared context is retained until the last execution sharing it releases it + if ($runtimeContext !== null && \in_array($runtimeContext, $this->contexts, true)) { + return null; + } + return $runtimeContext; } @@ -45,6 +51,18 @@ public function switchTo(string $execution): void $this->execution = $execution; } + /** + * Shares the context of the current execution with the given one, such as a child coroutine. + */ + public function share(string $execution): void + { + $runtimeContext = $this->get(); + + if ($runtimeContext !== null) { + $this->contexts[$execution] = $runtimeContext; + } + } + public function release(string $execution): void { unset($this->contexts[$execution]);