Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eee9285
refactor(sentry): replace manual flush with context lifecycle management
huangdijia Jun 12, 2026
9724b53
chore(sentry): bump sentry/sentry minimum version to ^4.28.0
huangdijia Jun 12, 2026
e78ed94
chore(sentry): sync sentry/sentry version in root composer.json
huangdijia Jun 12, 2026
9348304
♻️ refactor(sentry): remove unused SingletonAspect and clean up comm…
huangdijia Jun 12, 2026
3947e48
🐛 fix(sentry): move startContext into coroutine after context restore
huangdijia Jun 12, 2026
449e87d
✨ feat(sentry): add coroutine-safe RuntimeContextManager via class_map
huangdijia Jun 12, 2026
32760b8
📝 docs(sentry): add comment clarifying ContextArrayObject usage in c…
huangdijia Jun 12, 2026
59f842d
🐛 fix(sentry): ensure baseHub has a valid client in RuntimeContextMa…
huangdijia Jun 12, 2026
6978a6c
♻️ refactor(sentry): move context lifecycle into tracing aspect
huangdijia Jun 15, 2026
f7b5fce
📝 docs(sentry): correct type hints and comment for ContextArrayObject
huangdijia Jun 15, 2026
fd289ca
🐛 fix(sentry): run metrics collection within a coroutine context
huangdijia Jun 15, 2026
bced65b
🐛 fix(sentry): disable coordinator tracing span by default
huangdijia Jun 15, 2026
62ed785
♻️ refactor(sentry): remove coordinator tracing aspect
huangdijia Jun 15, 2026
4b159ae
Revert "🐛 fix(sentry): run metrics collection within a coroutine cont…
huangdijia Jun 15, 2026
d66bd09
🐛 fix(sentry): isolate sentry context per coroutine
huangdijia Jun 15, 2026
6e612c2
🐛 fix(sentry): treat sentry metrics/transport as coroutine backtrace …
huangdijia Jun 16, 2026
e3ddd4b
📦 build(sentry): require hyperf/coordinator
huangdijia Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"psr/http-factory-implementation": "*",
"psy/psysh": "^0.10.0 || ^0.11.0",
"ramsey/uuid": "^4.7",
"sentry/sentry": "^4.21.0",
"sentry/sentry": "^4.28.0",
"symfony/console": "^5.3 || ^6.0 || ^7.0",
"symfony/http-foundation": "^5.3 || ^6.0 || ^7.0",
"symfony/polyfill-php84": "^1.33",
Expand Down
299 changes: 299 additions & 0 deletions src/sentry/class_map/RuntimeContextManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
<?php

declare(strict_types=1);
/**
* This file is part of friendsofhyperf/components.
*
* @link https://github.com/friendsofhyperf/components
* @document https://github.com/friendsofhyperf/components/blob/main/README.md
* @contact huangdijia@gmail.com
*/

namespace Sentry\State;

use FriendsOfHyperf\Sentry\Util\ContextArrayObject;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Sentry\Tracing\PropagationContext;
use Throwable;

use function Hyperf\Support\make;

/**
* Manages runtime-local SDK state across different execution models.
*
* Lifecycle model:
* - The manager keeps a lazily initialized global context as fallback.
* - startContext() creates an isolated runtime context for the current
* execution key when no context is active yet.
* - endContext() flushes context resources and removes that context.
*
* @internal
*/
final class RuntimeContextManager
{
private const PROCESS_EXECUTION_CONTEXT_KEY = 'sentry.process.execution_context';

/**
* @var HubInterface
*/
private $baseHub;

/**
* @var null|RuntimeContext
*/
private $globalContext;

/**
* @var ContextArrayObject{string: RuntimeContext} map of active runtime contexts by their internal ID
*/
private $activeContexts;

/**
* @var ContextArrayObject{string: string} map of execution context keys to active runtime context IDs
*/
private $executionContextToRuntimeContext;

public function __construct(HubInterface $baseHub)
{
if (! $baseHub->getClient()) {
$baseHub = make(HubInterface::class);
}
$this->baseHub = $baseHub;
$this->globalContext = null;
// Using ContextArrayObject here since the manager is designed to be used in a single-threaded execution environment and does not require the overhead of thread-safe structures.
$context = new ContextArrayObject();
$this->activeContexts = $context;
$this->executionContextToRuntimeContext = $context;
}

/**
* Sets the current hub with context-aware behavior.
*
* If a runtime context is active for the current execution key, the hub is
* updated only for that active context. Otherwise, the baseline/global hub
* template is updated.
*
* @return bool Whether the hub was set on an active runtime context
*/
public function setCurrentHub(HubInterface $hub): bool
{
$executionContextKey = $this->getExecutionContextKey();

if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) {
$runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey];
$this->activeContexts[$runtimeContextId]->setHub($hub);

return true;
}

$this->baseHub = $hub;

if ($this->globalContext !== null) {
$this->globalContext->setHub($hub);
}

return false;
}

public function getCurrentHub(): HubInterface
{
return $this->getCurrentContext()->getHub();
}

public function getCurrentContext(): RuntimeContext
{
$executionContextKey = $this->getExecutionContextKey();

if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) {
$runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey];

return $this->activeContexts[$runtimeContextId];
}

return $this->getGlobalContext();
}

public function hasActiveContext(): bool
{
return $this->hasActiveContextForExecutionContextKey($this->getExecutionContextKey());
}

/**
* Starts an isolated context for the current execution key.
*/
public function startContext(): void
{
$executionContextKey = $this->getExecutionContextKey();

if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) {
// Nested start calls for the same execution key should be a no-op.
return;
}

$this->createContextForExecutionContextKey($executionContextKey);
}

/**
* Ends and flushes the active context for the current execution key.
*
* When no context is active for the key this is a no-op.
*/
public function endContext(?int $timeout = null): void
{
$executionContextKey = $this->getExecutionContextKey();

if (! $this->hasActiveContextForExecutionContextKey($executionContextKey)) {
return;
}

$runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey];
unset($this->executionContextToRuntimeContext[$executionContextKey]);

$this->removeContextById($runtimeContextId, $timeout);
}

private function createContextForExecutionContextKey(string $executionContextKey): void
{
$runtimeContextId = $this->generateRuntimeContextId();
$runtimeContext = new RuntimeContext($runtimeContextId, $this->createHubFromBaseHub());

$this->activeContexts[$runtimeContextId] = $runtimeContext;
$this->executionContextToRuntimeContext[$executionContextKey] = $runtimeContextId;
}

private function removeContextById(string $runtimeContextId, ?int $timeout = null): void
{
if (! isset($this->activeContexts[$runtimeContextId])) {
return;
}

$runtimeContext = $this->activeContexts[$runtimeContextId];
unset($this->activeContexts[$runtimeContextId]);
// Remove any key mappings that may still reference this context.
$this->removeExecutionContextMappingsForRuntimeContext($runtimeContextId);

$logger = $this->getLoggerFromHub($runtimeContext->getHub());

$this->flushRuntimeContextResources($runtimeContext, $timeout, $logger);
}

private function flushRuntimeContextResources(RuntimeContext $runtimeContext, ?int $timeout, LoggerInterface $logger): void
{
$hub = $runtimeContext->getHub();

// captureEvent can throw before transport send (for example from scope event processors
// or before_send callbacks), so we isolate failures and continue flushing other resources.
try {
$runtimeContext->getLogsAggregator()->flush($hub);
} catch (Throwable $exception) {
$logger->error('Failed to flush logs while ending a runtime context.', [
'exception' => $exception,
'runtime_context_id' => $runtimeContext->getId(),
]);
}

// Keep metrics flush independent from logs flush so one bad callback does not block the rest.
try {
$runtimeContext->getMetricsAggregator()->flush($hub);
} catch (Throwable $exception) {
$logger->error('Failed to flush trace metrics while ending a runtime context.', [
'exception' => $exception,
'runtime_context_id' => $runtimeContext->getId(),
]);
}

$client = $hub->getClient();

if ($client === null) {
return;
}

// Custom transports may throw from close(); endContext must stay best-effort and non-fatal.
try {
$client->flush($timeout);
} catch (Throwable $exception) {
$logger->error('Failed to flush the client transport while ending a runtime context.', [
'exception' => $exception,
'runtime_context_id' => $runtimeContext->getId(),
]);
}
}

private function removeExecutionContextMappingsForRuntimeContext(string $runtimeContextId): void
{
foreach ($this->executionContextToRuntimeContext as $executionContextKey => $mappedRuntimeContextId) {
if ($mappedRuntimeContextId === $runtimeContextId) {
unset($this->executionContextToRuntimeContext[$executionContextKey]);
}
}
}

private function hasActiveContextForExecutionContextKey(string $executionContextKey): bool
{
if (! isset($this->executionContextToRuntimeContext[$executionContextKey])) {
return false;
}

$runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey];

if (! isset($this->activeContexts[$runtimeContextId])) {
// Mapping points to a context that was already evicted/ended; drop the stale index entry.
unset($this->executionContextToRuntimeContext[$executionContextKey]);

return false;
}

return true;
}

private function createHubFromBaseHub(): HubInterface
{
if (! $this->baseHub instanceof Hub) {
return new Hub($this->baseHub->getClient());
}

$clonedScope = null;

$this->baseHub->configureScope(static function (Scope $scope) use (&$clonedScope): void {
$clonedScope = clone $scope;
// Do not inherit active traces into a new runtime context.
$clonedScope->setSpan(null);
$clonedScope->setPropagationContext(PropagationContext::fromDefaults());
});

return new Hub($this->baseHub->getClient(), $clonedScope ?? new Scope());
}

private function getLoggerFromHub(HubInterface $hub): LoggerInterface
{
$client = $hub->getClient();

if ($client === null) {
return new NullLogger();
}

return $client->getOptions()->getLoggerOrNullLogger();
}

private function generateRuntimeContextId(): string
{
return \sprintf('%s-%d', str_replace('.', '', uniqid('', true)), mt_rand());
}

private function getExecutionContextKey(): string
{
// All supported runtime modes currently use a process-local execution key.
return self::PROCESS_EXECUTION_CONTEXT_KEY;
}

private function getGlobalContext(): RuntimeContext
{
if ($this->globalContext === null) {
// Lazy fallback keeps baseline behavior when users do not opt into explicit context lifecycle.
$this->globalContext = new RuntimeContext('global', $this->baseHub);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Start a runtime context for request entrypoints

When a request/RPC/command is handled without going through Hyperf\Coroutine\Coroutine::create, it now falls back to this single global RuntimeContext, so Tracing\Listener\EventHandleListener::handleRequestReceived() sets spans/scopes on a hub shared by concurrent requests. The removed SentrySdkAspect previously kept the runtime manager in Hyperf's coroutine Context, but the only new startContext() calls are in the coroutine aspects, leaving normal server lifecycle events unisolated and allowing breadcrumbs/spans/metrics to bleed across requests until some later flush.

Useful? React with 👍 / 👎.

}

return $this->globalContext;
}
}
3 changes: 2 additions & 1 deletion src/sentry/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"require": {
"friendsofhyperf/support": "~3.1.61",
"hyperf/command": "~3.1.0",
"hyperf/coordinator": "~3.1.70",
"hyperf/context": "~3.1.0",
"hyperf/coroutine": "~3.1.0",
"hyperf/di": "~3.1.0",
Expand All @@ -33,7 +34,7 @@
"hyperf/http-server": "~3.1.0",
"hyperf/support": "~3.1.0",
"hyperf/tappable": "~3.1.0",
"sentry/sentry": "^4.21.0",
"sentry/sentry": "^4.28.0",
"symfony/polyfill-php85": "^1.33"
},
"suggest": {
Expand Down
6 changes: 3 additions & 3 deletions src/sentry/src/Aspect/CoroutineAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ protected function handleCreate(ProceedingJoinPoint $proceedingJoinPoint): void
Context::getOrSet($key, fn () => Context::get($key, coroutineId: $cid));
}

// Defer the flushing of events until the coroutine completes.
defer(fn () => SentrySdk::flush());
SentrySdk::startContext();

defer(fn () => SentrySdk::endContext());

// Continue the callable in the new Coroutine.
$callable();
};
}
Expand Down
Loading
Loading