diff --git a/docs/en/components/sentry.md b/docs/en/components/sentry.md index 935d74cd9..3c27d0aef 100644 --- a/docs/en/components/sentry.md +++ b/docs/en/components/sentry.md @@ -192,15 +192,24 @@ Set `monitor` to `false` on a crontab to skip check-ins for that task. ## Transport -The default binding uses `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`. -Tune its queue and concurrency with: +The default binding uses `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`. It uses a bounded +queue and a fixed worker pool: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` fails fast and drops telemetry when the queue is full, protecting +application memory when Sentry or the network is slow. Use a small positive value for bounded +backpressure. `CoHttpTransport::getStats()` exposes accepted, dropped, queued, in-flight, and worker +counts. + +Published configuration files are not overwritten during upgrades. If an existing application still +uses the previous `65535`, `1000`, and `-1` values, update them manually to enable the bounded defaults. + ## Sentry Development Documentation - OpenTelemetry Semantic Conventions [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/docs/zh-cn/components/sentry.md b/docs/zh-cn/components/sentry.md index d8ff7423f..cfca3b172 100644 --- a/docs/zh-cn/components/sentry.md +++ b/docs/zh-cn/components/sentry.md @@ -187,15 +187,23 @@ SENTRY_CRONS_TIMEZONE=UTC ## 传输 -默认绑定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。使用下面的环境 -变量调整队列和并发: +默认绑定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。它使用有界队列和固定 +worker 池: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` 会在队列已满时立即失败并丢弃遥测数据,避免 Sentry 或网络变慢 +时持续占用应用内存。如需有限背压,可设置较小的正数。`CoHttpTransport::getStats()` 可查看已接收、 +已丢弃、排队中、发送中和 worker 数量。 + +升级不会覆盖已经发布的配置文件。如果现有应用仍使用之前的 `65535`、`1000` 和 `-1`,需手动 +更新这些值才能启用新的有界默认配置。 + ## Sentry 开发文档 - OpenTelemetry 语义约定 [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/docs/zh-hk/components/sentry.md b/docs/zh-hk/components/sentry.md index 0c50a11ec..67bc8dc28 100644 --- a/docs/zh-hk/components/sentry.md +++ b/docs/zh-hk/components/sentry.md @@ -187,15 +187,23 @@ SENTRY_CRONS_TIMEZONE=UTC ## 傳輸 -默認綁定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。使用下面的環境 -變量調整隊列和併發: +默認綁定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。它使用有界隊列和固定 +worker 池: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` 會在隊列已滿時立即失敗並丟棄遙測數據,避免 Sentry 或網絡變慢 +時持續佔用應用內存。如需有限背壓,可設置較小的正數。`CoHttpTransport::getStats()` 可查看已接收、 +已丟棄、排隊中、發送中和 worker 數量。 + +升級不會覆蓋已經發布的配置文件。如果現有應用仍使用之前的 `65535`、`1000` 和 `-1`,需手動 +更新這些值才能啓用新的有界默認配置。 + ## Sentry 開發文檔 - OpenTelemetry 語義約定 [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/docs/zh-tw/components/sentry.md b/docs/zh-tw/components/sentry.md index a9f3b9221..c53c044b8 100644 --- a/docs/zh-tw/components/sentry.md +++ b/docs/zh-tw/components/sentry.md @@ -187,15 +187,23 @@ SENTRY_CRONS_TIMEZONE=UTC ## 傳輸 -預設繫結使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。使用下面的環境 -變數調整佇列和併發: +預設繫結使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。它使用有界佇列和固定 +worker 池: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` 會在佇列已滿時立即失敗並丟棄遙測資料,避免 Sentry 或網路變慢 +時持續佔用應用記憶體。如需有限背壓,可設定較小的正數。`CoHttpTransport::getStats()` 可檢視已接收、 +已丟棄、排隊中、傳送中和 worker 數量。 + +升級不會覆蓋已經發布的配置檔案。如果現有應用仍使用之前的 `65535`、`1000` 和 `-1`,需手動 +更新這些值才能啟用新的有界預設配置。 + ## Sentry 開發文件 - OpenTelemetry 語義約定 [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/src/sentry/README.md b/src/sentry/README.md index cbf880c7c..c6320bb7f 100644 --- a/src/sentry/README.md +++ b/src/sentry/README.md @@ -198,15 +198,24 @@ Set `monitor` to `false` on a crontab to skip check-ins for that task. ## Transport -The default binding uses `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`. -Tune its queue and concurrency with: +The default binding uses `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`. It uses a bounded +queue and a fixed worker pool: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` fails fast and drops telemetry when the queue is full, protecting +application memory when Sentry or the network is slow. Use a small positive value for bounded +backpressure. `CoHttpTransport::getStats()` exposes accepted, dropped, queued, in-flight, and worker +counts. + +Published configuration files are not overwritten during upgrades. If an existing application still +uses the previous `65535`, `1000`, and `-1` values, update them manually to enable the bounded defaults. + ## Sentry Development Documentation - OpenTelemetry Semantic Conventions [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/src/sentry/README_CN.md b/src/sentry/README_CN.md index 34ec3f908..260ec7862 100644 --- a/src/sentry/README_CN.md +++ b/src/sentry/README_CN.md @@ -189,15 +189,23 @@ SENTRY_CRONS_TIMEZONE=UTC ## 传输 -默认绑定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。使用下面的环境 -变量调整队列和并发: +默认绑定使用 `FriendsOfHyperf\Sentry\Transport\CoHttpTransport`。它使用有界队列和固定 +worker 池: ```env -SENTRY_TRANSPORT_CHANNEL_SIZE=65535 -SENTRY_TRANSPORT_CONCURRENT_LIMIT=1000 +SENTRY_TRANSPORT_CHANNEL_SIZE=512 +SENTRY_TRANSPORT_CONCURRENT_LIMIT=32 +SENTRY_TRANSPORT_TIMEOUT=0 SENTRY_HTTP_TIMEOUT=2.0 ``` +`SENTRY_TRANSPORT_TIMEOUT=0` 会在队列已满时立即失败并丢弃遥测数据,避免 Sentry 或网络变慢 +时持续占用应用内存。如需有限背压,可设置较小的正数。`CoHttpTransport::getStats()` 可查看已接收、 +已丢弃、排队中、发送中和 worker 数量。 + +升级不会覆盖已经发布的配置文件。如果现有应用仍使用之前的 `65535`、`1000` 和 `-1`,需手动 +更新这些值才能启用新的有界默认配置。 + ## Sentry 开发文档 - OpenTelemetry 语义约定 [semantic-conventions](https://github.com/open-telemetry/semantic-conventions/tree/main) diff --git a/src/sentry/publish/sentry.php b/src/sentry/publish/sentry.php index d23c25081..50df8497d 100644 --- a/src/sentry/publish/sentry.php +++ b/src/sentry/publish/sentry.php @@ -181,10 +181,11 @@ 'timezone' => env('SENTRY_CRONS_TIMEZONE', date_default_timezone_get()), ], - // Transport configuration - 'transport_channel_size' => (int) env('SENTRY_TRANSPORT_CHANNEL_SIZE', 65535), - 'transport_concurrent_limit' => (int) env('SENTRY_TRANSPORT_CONCURRENT_LIMIT', 1000), - 'transport_timeout' => (float) env('SENTRY_TRANSPORT_TIMEOUT', -1), + // Transport configuration: bounded queue, fixed worker pool, and enqueue timeout in seconds. + // Keep the timeout at 0 to drop telemetry immediately when the queue is full. + 'transport_channel_size' => (int) env('SENTRY_TRANSPORT_CHANNEL_SIZE', 512), + 'transport_concurrent_limit' => (int) env('SENTRY_TRANSPORT_CONCURRENT_LIMIT', 32), + 'transport_timeout' => (float) env('SENTRY_TRANSPORT_TIMEOUT', 0), 'http_timeout' => (float) env('SENTRY_HTTP_TIMEOUT', 2.0), ]; diff --git a/src/sentry/src/ConfigProvider.php b/src/sentry/src/ConfigProvider.php index 4fe185a72..9860e9c59 100644 --- a/src/sentry/src/ConfigProvider.php +++ b/src/sentry/src/ConfigProvider.php @@ -57,7 +57,8 @@ public function __invoke(): array ], 'listeners' => [ Listener\SetupSentryListener::class, - Listener\EventHandleListener::class => PHP_INT_MAX - 1, + Listener\RequestContextLifecycleListener::class => PHP_INT_MAX, + Listener\EventHandleListener::class => PHP_INT_MAX - 2, Crons\Listener\EventHandleListener::class => PHP_INT_MAX - 1, Metrics\Listener\DBPoolWatcher::class, Metrics\Listener\OnBeforeHandle::class, @@ -67,7 +68,7 @@ public function __invoke(): array Metrics\Listener\QueueWatcher::class, Metrics\Listener\RedisPoolWatcher::class, Metrics\Listener\RequestWatcher::class, - Tracing\Listener\EventHandleListener::class => PHP_INT_MAX, // !! Make sure it is the first one to handle the event + Tracing\Listener\EventHandleListener::class => PHP_INT_MAX - 1, ], 'annotations' => [ 'scan' => [ diff --git a/src/sentry/src/Listener/RequestContextLifecycleListener.php b/src/sentry/src/Listener/RequestContextLifecycleListener.php new file mode 100644 index 000000000..5ea89ba17 --- /dev/null +++ b/src/sentry/src/Listener/RequestContextLifecycleListener.php @@ -0,0 +1,44 @@ + SentrySdk::endContext()); + } +} diff --git a/src/sentry/src/Transport/CoHttpTransport.php b/src/sentry/src/Transport/CoHttpTransport.php index ffa5e658e..9c6358fc1 100644 --- a/src/sentry/src/Transport/CoHttpTransport.php +++ b/src/sentry/src/Transport/CoHttpTransport.php @@ -14,7 +14,6 @@ use Hyperf\Contract\ConfigInterface; use Hyperf\Coordinator\Constants; use Hyperf\Coordinator\CoordinatorManager; -use Hyperf\Coroutine\Concurrent; use Hyperf\Engine\Channel; use Hyperf\Engine\Coroutine; use Psr\Container\ContainerInterface; @@ -36,50 +35,104 @@ class CoHttpTransport implements TransportInterface protected ?Coroutine $workerWatcher = null; - protected ?Concurrent $concurrent = null; - protected ?ClientBuilder $clientBuilder = null; - protected int $channelSize = 65535; + protected ?TransportInterface $httpTransport = null; + + protected int $channelSize = 512; + + protected int $workerCount = 32; + + protected float $timeout = 0; - protected float $timeout = -1; + protected int $inFlight = 0; + + protected int $accepted = 0; + + protected int $dropped = 0; public function __construct( protected ContainerInterface $container, ) { $config = $this->container->get(ConfigInterface::class); - $channelSize = (int) $config->get('sentry.transport_channel_size', 65535); + $channelSize = (int) $config->get('sentry.transport_channel_size', 512); if ($channelSize > 0) { $this->channelSize = $channelSize; } - $concurrentLimit = (int) $config->get('sentry.transport_concurrent_limit', 1000); + $concurrentLimit = (int) $config->get('sentry.transport_concurrent_limit', 32); if ($concurrentLimit > 0) { - $this->concurrent = new Concurrent($concurrentLimit); + $this->workerCount = $concurrentLimit; } - $timeout = (float) $config->get('sentry.transport_timeout', -1); - if ($timeout > 0) { + $timeout = (float) $config->get('sentry.transport_timeout', 0); + if ($timeout >= 0) { $this->timeout = $timeout; } } public function send(Event $event): Result { + if ($this->workerExited) { + ++$this->dropped; + + return new Result(ResultStatus::skipped(), $event); + } + $this->loop(); $chan = $this->chan; - // push event to channel, if timeout is set, it will wait for the specified time - $result = $chan?->push($event, $this->timeout) ? ResultStatus::success() : ResultStatus::skipped(); + // Swoole treats a zero timeout as an infinite wait, so check capacity first + // to provide fail-fast backpressure without suspending the application coroutine. + if ($this->timeout === 0.0 && $chan !== null && $chan->getLength() >= $chan->getCapacity()) { + ++$this->dropped; + + return new Result(ResultStatus::skipped(), $event); + } + + if (! $chan?->push($event, $this->timeout)) { + ++$this->dropped; - return new Result($result, $event); + return new Result(ResultStatus::skipped(), $event); + } + + ++$this->accepted; + + return new Result(ResultStatus::success(), $event); } public function close(?int $timeout = null): Result { + if ($timeout === null || $timeout <= 0) { + return new Result(ResultStatus::success()); + } + + $deadline = microtime(true) + $timeout; + while (! $this->isIdle()) { + if (microtime(true) >= $deadline) { + return new Result(ResultStatus::skipped()); + } + + msleep(1); + } + return new Result(ResultStatus::success()); } + /** + * @return array{accepted: int, dropped: int, queued: int, in_flight: int, workers: int} + */ + public function getStats(): array + { + return [ + 'accepted' => $this->accepted, + 'dropped' => $this->dropped, + 'queued' => $this->chan?->getLength() ?? 0, + 'in_flight' => $this->inFlight, + 'workers' => $this->workerCount, + ]; + } + protected function loop(): void { if ($this->workerExited) { @@ -92,49 +145,50 @@ protected function loop(): void $this->chan = new Channel($this->channelSize); - Coroutine::create(function () { - while (true) { - $transport = $this->makeHttpTransport(); - $logger = $this->clientBuilder?->getLogger(); - - while (true) { - /** @var null|Event|false $event */ - $event = $this->chan?->pop(); - - if (! $event) { - break 2; - } - - try { - $callable = static fn () => $transport->send($event); - if ($this->concurrent !== null) { - $this->concurrent->create($callable); - } else { - Coroutine::create($callable); - } - } catch (Throwable $e) { - $logger?->error('Failed to send event to Sentry: ' . $e->getMessage(), ['exception' => $e]); - $transport->close(); - - break; - } finally { - // Prevent memory leak - $event = null; - } - } + for ($worker = 0; $worker < $this->workerCount; ++$worker) { + Coroutine::create(fn () => $this->runWorker()); + } + + $this->watchWorkerExit(); + } + + protected function runWorker(): void + { + $chan = $this->chan; + if ($chan === null) { + return; + } + + $transport = $this->getHttpTransport(); + $logger = $this->clientBuilder?->getLogger(); + + while (true) { + /** @var Event|false $event */ + $event = $chan->pop(); + if (! $event) { + break; } - $this->closeChannel(); - }); + ++$this->inFlight; + try { + $transport->send($event); + } catch (Throwable $e) { + $logger?->error('Failed to send event to Sentry: ' . $e->getMessage(), ['exception' => $e]); + } finally { + --$this->inFlight; + $event = null; + } + } + } + + protected function watchWorkerExit(): void + { $this->workerWatcher ??= Coroutine::create(function () { if (CoordinatorManager::until(Constants::WORKER_EXIT)->yield()) { - // sleep before setting workerExited to prevent busy-waiting - msleep(100); - $this->workerExited = true; - while (! $this->chan?->isEmpty()) { + while (! $this->isIdle()) { msleep(100); } @@ -143,6 +197,11 @@ protected function loop(): void }); } + protected function getHttpTransport(): TransportInterface + { + return $this->httpTransport ??= $this->makeHttpTransport(); + } + protected function makeHttpTransport(): TransportInterface { $this->clientBuilder ??= $this->container->get(ClientBuilder::class); @@ -164,4 +223,9 @@ protected function closeChannel(): void $this->chan = null; } } + + protected function isIdle(): bool + { + return ($this->chan === null || $this->chan->isEmpty()) && $this->inFlight === 0; + } } diff --git a/tests/Sentry/CoHttpTransportTest.php b/tests/Sentry/CoHttpTransportTest.php new file mode 100644 index 000000000..f224d0e18 --- /dev/null +++ b/tests/Sentry/CoHttpTransportTest.php @@ -0,0 +1,145 @@ +config; + } + + throw new RuntimeException("Unexpected container entry: {$id}"); + } + + public function has(string $id): bool + { + return $id === ConfigInterface::class; + } +} + +final class SlowTransport implements TransportInterface +{ + public int $completed = 0; + + public function send(Event $event): Result + { + \Swoole\Coroutine::sleep(0.05); + ++$this->completed; + + return new Result(ResultStatus::success(), $event); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } +} + +final class TestCoHttpTransport extends CoHttpTransport +{ + public function __construct(ContainerInterface $container, private TransportInterface $transport) + { + parent::__construct($container); + } + + public function shutdown(): void + { + $this->workerExited = true; + $this->closeChannel(); + } + + protected function watchWorkerExit(): void + { + } + + protected function makeHttpTransport(): TransportInterface + { + return $this->transport; + } +} + +/** + * @internal + */ +final class CoHttpTransportTest extends TestCase +{ + public function testTransportBoundsQueuedEventsAndUsesFixedWorkers(): void + { + $config = new Config([ + 'sentry' => [ + 'transport_channel_size' => 2, + 'transport_concurrent_limit' => 1, + 'transport_timeout' => 0, + ], + ]); + $container = new SentryTransportContainer($config); + + $slowTransport = new SlowTransport(); + $transport = new TestCoHttpTransport($container, $slowTransport); + + try { + self::assertSame('SUCCESS', (string) $transport->send(Event::createEvent())->getStatus()); + \Swoole\Coroutine::sleep(0.005); + + self::assertSame(1, $transport->getStats()['in_flight']); + self::assertSame('SUCCESS', (string) $transport->send(Event::createEvent())->getStatus()); + self::assertSame('SUCCESS', (string) $transport->send(Event::createEvent())->getStatus()); + self::assertSame('SKIPPED', (string) $transport->send(Event::createEvent())->getStatus()); + self::assertSame( + [ + 'accepted' => 3, + 'dropped' => 1, + 'queued' => 2, + 'in_flight' => 1, + 'workers' => 1, + ], + $transport->getStats() + ); + + self::assertSame('SUCCESS', (string) $transport->close(1)->getStatus()); + self::assertSame(3, $slowTransport->completed); + self::assertSame(0, $transport->getStats()['queued']); + self::assertSame(0, $transport->getStats()['in_flight']); + } finally { + $transport->shutdown(); + } + } + + public function testTransportDropsNewEventsAfterShutdown(): void + { + $config = new Config(['sentry' => []]); + $container = new SentryTransportContainer($config); + + $transport = new TestCoHttpTransport($container, new SlowTransport()); + $transport->shutdown(); + + self::assertSame('SKIPPED', (string) $transport->send(Event::createEvent())->getStatus()); + self::assertSame(1, $transport->getStats()['dropped']); + } +} diff --git a/tests/Sentry/RequestContextLifecycleListenerTest.php b/tests/Sentry/RequestContextLifecycleListenerTest.php new file mode 100644 index 000000000..606151aec --- /dev/null +++ b/tests/Sentry/RequestContextLifecycleListenerTest.php @@ -0,0 +1,146 @@ +setUpRuntimeContextManager(); + + $listener = new RequestContextLifecycleListener(); + $contexts = new \Swoole\Coroutine\Channel(2); + + for ($index = 0; $index < 2; ++$index) { + \Swoole\Coroutine::create(static function () use ($contexts, $listener): void { + $listener->process((object) []); + $contexts->push([ + 'hub' => spl_object_id(SentrySdk::getCurrentHub()), + 'context' => SentrySdk::getCurrentRuntimeContext()->getId(), + ]); + }); + } + + $first = $contexts->pop(); + $second = $contexts->pop(); + + self::assertNotSame('global', $first['context']); + self::assertNotSame('global', $second['context']); + self::assertNotSame($first['context'], $second['context']); + self::assertNotSame($first['hub'], $second['hub']); + } + + public function testItDoesNotEndAPreExistingRuntimeContext(): void + { + $this->setUpRuntimeContextManager(); + + $listener = new RequestContextLifecycleListener(); + $contexts = new \Swoole\Coroutine\Channel(1); + + \Swoole\Coroutine::create(static function () use ($contexts, $listener): void { + SentrySdk::startContext(); + $expectedContext = SentrySdk::getCurrentRuntimeContext()->getId(); + + defer(static function () use ($contexts, $expectedContext): void { + $actualContext = SentrySdk::getCurrentRuntimeContext()->getId(); + SentrySdk::endContext(); + $contexts->push([$expectedContext, $actualContext]); + }); + + $listener->process((object) []); + }); + + [$expectedContext, $actualContext] = $contexts->pop(); + + self::assertSame($expectedContext, $actualContext); + } + + public function testItEndsTheRuntimeContextCreatedForARequest(): void + { + $this->setUpRuntimeContextManager(); + + $listener = new RequestContextLifecycleListener(); + $contexts = new \Swoole\Coroutine\Channel(1); + + \Swoole\Coroutine::create(static function () use ($contexts, $listener): void { + defer(static function () use ($contexts): void { + $contexts->push(SentrySdk::getCurrentRuntimeContext()->getId()); + }); + + $listener->process((object) []); + }); + + self::assertSame('global', $contexts->pop()); + } + + private function setUpRuntimeContextManager(): void + { + self::assertFalse(class_exists(RuntimeContextManager::class, false)); + require dirname(__DIR__, 2) . '/src/sentry/class_map/RuntimeContextManager.php'; + + $transport = new class implements TransportInterface { + public function send(Event $event): Result + { + return new Result(ResultStatus::success(), $event); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } + }; + $client = ClientBuilder::create(['dsn' => null, 'transport' => $transport])->getClient(); + $baseHub = new Hub($client); + + $this->setSentrySdkProperty('currentHub', $baseHub); + $this->setSentrySdkProperty('runtimeContextManager', new RuntimeContextManager($baseHub)); + } + + private function setSentrySdkProperty(string $property, mixed $value): void + { + $reflection = new ReflectionProperty(SentrySdk::class, $property); + $reflection->setValue(null, $value); + } +}