diff --git a/composer.json b/composer.json index 2042b86bc..b9761f491 100644 --- a/composer.json +++ b/composer.json @@ -199,7 +199,7 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "psy/psysh": "^0.12.22", - "sentry/sentry": "^4.27", + "sentry/sentry": "dev-master", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1", "symfony/dom-crawler": "^8.1", diff --git a/docs/plans/2026-09-04-0500-sentry-runtime-context-integration.md b/docs/plans/2026-09-04-0500-sentry-runtime-context-integration.md new file mode 100644 index 000000000..c9baaa50e --- /dev/null +++ b/docs/plans/2026-09-04-0500-sentry-runtime-context-integration.md @@ -0,0 +1,263 @@ +# Sentry Runtime Context Integration + +## Goal + +Adopt Sentry PHP's runtime-context storage contract so Logs and Trace Metrics are isolated and flushed with each framework-owned Hypervel execution boundary. Keep Hypervel's coroutine-aware Hub because it gives child coroutines isolated scopes without constructing a full SDK context for every child. Cover HTTP requests, queue jobs, scheduled tasks, and every WebSocket callback while preserving the existing public middleware, configuration, facade, and pooled non-blocking transport APIs. + +The implementation must have these properties: + +- Sentry-inactive applications leave no runtime storage registered and add no execution listeners or propagation hook. +- An active application creates one SDK runtime context at each uncovered HTTP, queue, scheduled-task, WebSocket opening, WebSocket message, and WebSocket closing boundary. A nested boundary in the same coroutine reuses the active context. +- Child coroutines share their execution's Logs and Metrics buffers through counted ownership, but retain the existing eager Hub-scope and request snapshots. No child constructs another SDK Hub, Scope, RuntimeContext, or aggregator pair. +- The last owner to exit flushes once, regardless of whether the root or a child exits first. A holder is never copied by generic context-copy APIs without its owner count being retained. +- Sentry delivery coroutines inherit no application scope, request, or runtime context. +- Root console/global telemetry is flushed at application termination; graceful queue and worker shutdown retain their bounded delivery drains. +- Existing Laravel-shaped APIs remain intact. New Hypervel-owned code uses a `State` directory mirroring the SDK's `Sentry\State` namespace and follows the package's constructor-injection conventions. + +## Verified constraints + +- `sentry/sentry` on `dev-master` contains the merged `RuntimeContextStorageInterface`, `SentrySdk::setRuntimeContextStorage()`, `startContext(?HubInterface $hub = null)`, `endContext()`, and `flush()` APIs. No released constraint contains them yet, so both Composer manifests intentionally use `dev-master` for this work. +- `RuntimeContextManager::startContext($hub)` uses the provided Hub as-is while creating the RuntimeContext, Logs aggregator, Metrics aggregator, and context ID and resetting fatal-error-handler state. The default path also creates an SDK Hub and Scope. Hypervel must pass its existing Hub at execution boundaries and share the resulting RuntimeContext with child coroutines, avoiding the redundant boundary Hub/Scope, the follow-up `setCurrentHub()` and its storage lookup, and a full context per child. +- `RuntimeContextStorageInterface` expressly permits parent and child executions to share a context when storage retains it until every owner releases it. +- Hypervel's custom `Hub` is still needed: its layer stack and last event ID live in `CoroutineContext`, and the existing child hook eagerly clones every Layer/Scope and Request snapshot. Lazy parent lookup would lose the snapshot when a detached child first accesses Sentry after its parent exits. +- The custom Hub's complete `HubInterface` behavior and sampling path match the installed `dev-master` SDK Hub after accounting for its coroutine-local stack, last-event ID, bootstrap-scope, and worker-shared client adaptations. It needs no speculative rewrite in this change. +- `CoroutineContext` omits top-level values implementing `NonCopyableContext` from `fork()` and other generic copies. The Sentry hook can therefore be the only path that shares and counts the runtime holder. +- `Coroutine::afterCreated()` callbacks can be registered twice in supported Testbench application reloads before the global test cleanup runs. Storage inheritance must be idempotent for an already-populated child slot or an extra retain would never be released. +- `SentrySdk::setRuntimeContextStorage()` discards the current coroutine's active context whenever provider boot replaces or clears the registered storage. On a mid-test `resetApplicationWithConfig()`, the previous boundary's deferred `endContext()` therefore no-ops; tests must not mistake that intentional discard-on-reconfiguration behavior for a missing flush. +- Queue workers and scheduled tasks already execute work in finite coroutines. A synchronous queue job inside an existing command shares that command coroutine and therefore its existing context; a second terminal-event ownership system would be both early on failures and unnecessary. +- Sentry Logs and Trace Metrics select their aggregators through `SentrySdk::getCurrentRuntimeContext()`. Their deprecated `enable_logs` and `enable_metrics` options no longer gate manual APIs or integrations, so storage and execution boundaries cannot depend on either option. +- WebSocket handshakes call `Router::dispatchToCallback()` and do not pass through the HTTP kernel's global middleware. `ConnectionOpened` and `ConnectionClosed` occur after their application callbacks have started or completed, so new before-callback events are required for complete context ownership. +- Swoole defers are LIFO. A context end registered by `ConnectionOpening` runs after the later `deferOnOpen()`, keeping `ConnectionOpened` listeners and `onOpen()` inside the handshake context. +- The Sentry split already directly requires Hypervel Context and Coroutine. It does not require WebSocket Server and must not gain that optional dependency: `::class` is a safe string for an absent class, and the event dispatcher supports such listener keys. This matches the package's existing optional Sanctum event registration. +- An active Sentry install without WebSocket Server pays only the one-time boot classification and registration of the optional event-name strings. No WebSocket callback, SDK context construction, or recurring runtime work occurs when those events do not exist or fire. + +## Runtime state design + +Add a new internal `src/sentry/src/State/` directory mirroring the SDK's `Sentry\State` namespace, with three classes. + +### `SharedRuntimeContext` + +This small holder implements `NonCopyableContext`, owns one opaque SDK `RuntimeContext`, and starts with one owner. `retain()` increments the owner count. `release()` decrements it and returns the SDK context only for the final owner; earlier releases return `null`. It exposes no count or mutation API beyond those ownership operations. + +```php +class SharedRuntimeContext implements NonCopyableContext +{ + private int $owners = 1; + + public function __construct( + private readonly RuntimeContext $runtimeContext, + ) { + } + + public function getRuntimeContext(): RuntimeContext + { + return $this->runtimeContext; + } + + public function retain(): void + { + ++$this->owners; + } + + public function release(): ?RuntimeContext + { + --$this->owners; + + return $this->owners === 0 ? $this->runtimeContext : null; + } +} +``` + +No destructor, secondary per-owner wrapper, lock, or underflow guard is needed. Retain/set and forget/release do not suspend, and all supported release paths are owned by the storage and Hypervel defer lifecycle. + +### `CoroutineRuntimeContextStorage` + +Implement `RuntimeContextStorageInterface` over one private `__sentry.runtime_context` key in `CoroutineContext`: + +- `set()` wraps the SDK context in a new `SharedRuntimeContext` with the root owner. +- `get()` returns the holder's opaque SDK context. +- `remove()` forgets the current slot before releasing its owner. It returns a context only when that release was final, which makes the SDK's existing `endContext()` the single flush owner. +- `inheritFrom(ArrayObject $context, ?ArrayObject $parentContext): bool` is the package-only child fast path. It returns `false` when the child already has a holder, the parent is gone, or the parent has no holder. Otherwise it retains the parent holder, writes the same holder into the child container, and returns `true` so the caller registers exactly one deferred end. + +The child-slot guard is required, not defensive decoration: after Testbench reloads an active application, both registered propagation callbacks run for the next child. The second callback must neither retain again nor register another end defer. + +### `RuntimeContextBoundary` + +Create a stateless, unbound concrete service so Hypervel auto-singletons it safely for the worker. Inject `HubInterface` and `CoroutineRuntimeContextStorage`. + +```php +public function start(): void +{ + if (! Coroutine::inCoroutine() || $this->runtimeContextStorage->get() !== null) { + return; + } + + SentrySdk::startContext($this->hub); + Coroutine::defer(static function (): void { + SentrySdk::endContext(); + }); +} +``` + +The outside-coroutine branch is required because there is no coroutine defer to own the end; root console telemetry uses the global SDK context and application-termination flush instead. The active-context branch makes nested boundaries reuse their owner rather than registering an end that could release it early. Pass the Hypervel Hub directly so the SDK does not create a disposable Hub and Scope, and register the defer immediately after `startContext()` so an application callback failure still releases the context. + +## Provider boot and propagation + +Update `src/sentry/src/SentryServiceProvider.php` in this order: + +1. Compute active state once. On every boot, explicitly own the SDK's process-global storage registration: resolve `CoroutineRuntimeContextStorage` only when active, then call `SentrySdk::setRuntimeContextStorage($active ? $storage : null)` before resolving the package Hub. This clears storage left by a prior active Testbench application before an inactive application's Hub is installed. Registration is boot-only and unconditional with respect to Logs/Metrics options. +2. Eagerly resolve `HubInterface` as today. Construct the custom Hub with a clone of the current no-client SDK scope, following current `sentry-laravel`'s `cloneCurrentHubScope()` behavior. This preserves tags and other scope data configured through `withExceptions()` before the real client exists. Do not clone a Hub that already has a client. +3. Before `bootFeatures()`, register one closure for these five start events: `JobProcessing`, `ScheduledTaskStarting`, `ConnectionOpening`, `MessageReceived`, and `ConnectionClosing`. The closure resolves `RuntimeContextBoundary` from the current container at dispatch time, so application/test rebindings work. Do not add `class_exists()`, a Composer dependency, or a suggestion for the optional WebSocket event classes. +4. Register the active-only child propagation hook before application work can create children. +5. Register the active-only `SentrySdk::flush()` terminating callback in the same non-null storage block. Run it only when `CoroutineRuntimeContextStorage::get()` is `null`: HTTP termination occurs before its deferred context end and must not double-flush, while root console/global buffers still need flushing. +6. Keep feature boot, ordinary Sentry events, middleware, and tracing behavior in their current relative order after the early runtime-context registrations. Guard feature behavior with the explicit active flag; use the storage value only where the instance is required. Event dispatcher insertion order then guarantees queue/scheduling Sentry feature listeners see the installed runtime context. + +Change `registerCoroutineContextPropagation()` to accept the resolved storage instance. The hook must remain direct and allocation-conscious without adding a direct `hypervel/engine` dependency: + +- Fetch the current raw container once through `CoroutineContext::getContainer()`. The hook always runs inside a newly created coroutine, so narrow the current container to `ArrayObject` and the parent container to `?ArrayObject` with `@var`; do not add unreachable runtime guards or widen `inheritFrom()` to accept `array`. +- If the delivery marker is present, return immediately without looking up the parent or cloning anything. +- Fetch the parent raw container once. Reuse those two containers for the Hub stack, Request, and runtime-holder work; the current code performs repeated engine lookups for each value. +- Prefer an already-installed child stack/request (from `fork()`), otherwise use the parent's. Preserve the current eager Layer/Scope and Request cloning semantics. +- Perform runtime inheritance last. If `inheritFrom()` returns `true`, immediately defer `SentrySdk::endContext()` in the child. + +This keeps the normal no-parent path at two context lookups, reduces today's repeated lookups, and adds only a holder retain/write/defer for a child of an active execution. Grandchildren remain safe after an intermediate parent exits because each child owns the shared holder directly; no parent-ID walk occurs later. + +## Execution boundaries + +### HTTP + +Keep the public `FlushEventsMiddleware` class and its outermost position. Inject `RuntimeContextBoundary`, call `start()` before `$next`, and remove the direct `Integration::flushEvents()` defer. Update the provider and tracing comments to say that the outer runtime-context end defer runs after later tracing and after-response defers. + +### Queue and scheduling + +The early provider listeners start contexts before `QueueFeature` and `ConsoleSchedulingFeature` create scopes or spans. Remove explicit pre-pop or terminal flushes from: + +- `TracksPushedScopesAndSpans::maybePopScope()`; +- `QueueFeature::handleJobExceptionOccurredQueueEvent()`; +- `ConsoleSchedulingFeature::handleScheduledTaskFinished()`; +- `ConsoleSchedulingFeature::handleScheduledTaskFailed()`. + +Scope/span cleanup defers were registered later and therefore run before runtime-context end. Failed-job exception reporting also completes inside the finite job coroutine before that defer runs. Keep `QueueFeature::handleWorkerStoppingQueueEvent()` as a bounded drain. + +### WebSockets + +Add two Laravel-shaped public lifecycle events to `src/websocket-server/src/Events/`: + +```php +new ConnectionOpening(int $fd, Hypervel\Http\Request $request, string $server = 'websocket'); +new ConnectionClosing(int $fd, int $reactorId, string $server = 'websocket'); +``` + +In `WebSocketServer\Server`: + +- Dispatch guarded `ConnectionOpening` after the fd and bridged request are installed, before generic `RequestReceived`, security validation, routing, and handler resolution. Its throwable/cancellation behavior stays inside the existing handshake policy: ordinary failures produce the rendered handshake failure; cancellation escapes to the outer cleanup. +- Reuse `MessageReceived` at its current location after handler resolution/type validation and before `onMessage()`. Moving it earlier would start unmatched tracing/instrumentation lifecycles for invalid handlers. +- Dispatch guarded `ConnectionClosing` after a registered handler class is found and before logging, handler resolution, `onClose()`, and `ConnectionClosed`. Follow the surrounding per-step policy: cancellation returns, other throwables are reported, later close work continues, and the existing `finally` always clears the fd and connection context. + +Sentry's five-event listener list makes opening, message, and closing contexts automatic only when Sentry is active. WebSocket applications without active Sentry retain `hasListeners()` fast paths and create no new event objects. Active Sentry intentionally creates one RuntimeContext and its Logs and Metrics aggregators per message for isolation while reusing the Hypervel Hub; this is the required per-message cost, not a child-propagation cost. + +## Transport and flushing + +### Delivery marker + +Add `public const string DELIVERY_CONTEXT_KEY = '__sentry.delivery'` to `HttpPoolTransport`. In its owned child wrapper, set the marker before invoking the child runner, so the Sentry propagation hook sees it before its callback body begins. The hook skips Hub/request cloning and runtime ownership for the entire delivery coroutine. Keep all current transport checkout, failure, WaitGroup generation, release/discard, and cancellation behavior unchanged. + +### SDK flush facade + +Simplify `Integration` around the SDK's complete flush facade: + +- `drainEvents()` first calls `SentrySdk::flush()` so current Logs, Metrics, and accepted client work are published. It then preserves the existing null-client success result, positive timeout normalization, and final `client->flush($timeout)` bounded wait. +- Remove the now-unused internal `flushEvents()` wrapper, the private duplicate flush helper, and now-unused Logs/Metrics imports. The SDK facade resolved the issue for which upstream introduced `flushEvents()`, and Hypervel has no production caller. + +The extra no-timeout client flush inside `SentrySdk::flush()` is only the pooled transport's WaitGroup count observation; the following positive flush captures and waits for the accepted generation. Keep the worker-exit drain and transport shutdown in `EventHandler::workerExitHandler()` unchanged. + +## Configuration and documentation + +- Keep the existing `enable_logs` and `enable_metrics` keys because they are current Sentry Laravel configuration APIs, even though the SDK marks them deprecated. Remove false unsupported comments, restore `enable_metrics` to the SDK/upstream default of `true`, and describe `logs_channel_level` and `log_flush_threshold` without unsupported wording. Do not introduce another configuration switch for runtime contexts. +- Remove test setup that sets `enable_logs` as though it activated the Logs API. Keep config parsing coverage for both deprecated compatibility keys without using either as a runtime gate. +- Update `src/docs/sentry.md` to remove the Logs and Trace Metrics warnings, state that both are execution-isolated, define an execution as including its spawned child coroutines, show the current `Sentry\traceMetrics()` API, and mention WebSocket callbacks among isolated lifecycles. Explain that the deprecated `enable_logs` and `enable_metrics` options are compatibility no-ops and point to `before_send_log` / `before_send_metric` as kill switches. Keep delivery/shutdown wording consistent with final-owner context flush plus the asynchronous pool. +- Update `src/docs/websockets.md` to list all six events in lifecycle order with timing and payloads: `ConnectionOpening`, `ConnectionOpened`, `MessageReceived`, `MessageHandled` (currently missing), `ConnectionClosing`, and `ConnectionClosed`. +- Do not add a Laravel porting-guide entry: the two WebSocket events are Hypervel-native APIs and require no Laravel migration action. + +## File-by-file work + +1. Keep the approved `dev-master` constraint in root `composer.json` and `src/sentry/composer.json`; run `composer update sentry/sentry` so the installed SDK contains the caller-provided Hub API, without committing the ignored root lock. +2. Add `src/sentry/src/State/SharedRuntimeContext.php`. +3. Add `src/sentry/src/State/CoroutineRuntimeContextStorage.php`. +4. Add `src/sentry/src/State/RuntimeContextBoundary.php`. +5. Update `src/sentry/src/SentryServiceProvider.php` for storage registration, scope cloning, early boundaries, terminating flush, and optimized child propagation. +6. Update `src/sentry/src/Transport/HttpPoolTransport.php` with the delivery marker. +7. Update `src/sentry/src/Http/FlushEventsMiddleware.php` to start the boundary while preserving its public name and middleware position. +8. Update `src/sentry/src/Integration.php` to remove the superseded internal flush wrapper and retain bounded drain semantics through `SentrySdk::flush()`. +9. Remove superseded operation flushes/imports/comments from `TracksPushedScopesAndSpans.php`, `QueueFeature.php`, and `ConsoleSchedulingFeature.php`; update the defer-order comment in `Tracing/Middleware.php`. +10. Add `ConnectionOpening.php` and `ConnectionClosing.php`, then update WebSocket `Server.php` at the verified lifecycle points. +11. Update Sentry config and the two canonical documentation pages. Remove every statement that Logs or Trace Metrics are unsupported. +12. Update the affected tests serially, centralize exact captured-event filtering in `SentryTestCase`, and reset the SDK's process-global registration through `AfterEachTestSubscriber`. Run each test file as soon as it changes. +13. Isolate the Horizon process-niceness test in a separate process and assert its change from the inherited baseline. This prevents the full parallel suite from permanently changing a reusable worker's process-global niceness or assuming it started at zero. + +## Testing + +### Runtime storage and ownership + +Add focused tests under `tests/Sentry/State/`: + +- `CoroutineRuntimeContextStorageTest` covers set/get/remove, no-context removal, parent/child final-release behavior, and the already-populated-child idempotence guard. Assert returned SDK context identity; do not expose an owner-count production API for tests. Generic-copy omission and both coroutine exit orders are observable propagation behaviors and belong only in the end-to-end propagation tests. +- `RuntimeContextBoundaryTest` proves a coroutine boundary starts once with the exact Hypervel Hub, flushes at coroutine exit, reuses an already-active context, and does nothing outside a coroutine where no defer can own cleanup. + +Extend `CoroutineContextPropagationTest` to prove: + +- existing Hub Layer/Scope and Request snapshot behavior is unchanged for `create()`, full `fork()`, and selective `fork()`; +- an active runtime context is shared and released exactly once through `create()`, full `fork()`, and selective `fork()` children without constructing child SDK contexts, including a grandchild that outlives its intermediate parent; +- one log and metric buffer flushes exactly once when child-first and parent-first exit orders are forced with channels; +- reloading the application before creating a child does not double-retain or suppress the final flush when duplicate propagation hooks run; +- a marked delivery child gets no Hub stack, Request clone, or shared runtime owner. + +### Boundary integration and flushes + +- Update `FlushEventsMiddlewareTest` to inject the real boundary and assert context lifetime/flush ordering rather than a direct flush defer. +- Update `FlushLifecycleTest` for the two-step bounded drain: `SentrySdk::flush()` publishes Logs and Metrics before the final positive client wait. Replace terminal scheduled-task/pre-pop flush expectations with execution-end behavior, while retaining root console termination, graceful queue drain, immediate-stop, and worker-exit coverage. +- Extend `ServiceProviderListenerRegistrationTest` to assert all five start listeners are registered, the queue and scheduling boundary listeners precede their corresponding Sentry feature listeners, and every boundary listener resolves the service from the container at event time. The three new WebSocket events have no separate Sentry feature listeners to order against. +- Extend `ServiceProviderWithoutDsnTest` to assert through public behavior that inactive applications clear any prior SDK runtime storage and register none of the boundary listeners or coroutine propagation work. +- Add a provider regression proving scope data configured on the no-client SDK Hub before provider resolution survives when the real Hypervel Hub/client is installed. +- Adjust queue and scheduling integration assertions only where the new execution-end owner changes flush timing; keep their scope, span, exception, and check-in behavior intact. +- Update `HttpPoolTransportTest` to assert the delivery marker is installed before child startup hooks while preserving all existing ownership/failure tests. +- Reset Sentry's registered runtime storage and Hub once in `AfterEachTestSubscriber::flushSentryState()` rather than in individual tests. +- Add `getCapturedSentryEventsOfType()` to `SentryTestCase` and use it for exact event-type filtering across the affected Sentry tests. + +### Logs, Metrics, and WebSockets + +- Add concurrent Sentry integration coverage showing two root execution contexts cannot see or flush each other's Logs or Trace Metrics, and each envelope contains only its own items. +- Extend `ServerHandshakeTest` for `ConnectionOpening` order/payload, its ordinary-failure and cancellation policies, and cleanup. +- Extend `ServerTest` for `ConnectionClosing` order/payload. A closing-listener throwable must not prevent `onClose()`, `ConnectionClosed`, or fd/context cleanup; cancellation must stop later close callbacks while still cleaning up. +- Add `tests/Sentry/WebSocketRuntimeContextTest.php` for the cross-package behavior: an invalid `Sec-WebSocket-Key` after opening telemetry still ends and flushes one context; telemetry from `onOpen()` is included in the handshake's single context flush; concurrent messages flush isolated buffers; and close telemetry flushes after `onClose()`. +- Update config tests for the supported/default Metrics state and retain environment normalization coverage. + +These tests cover public behavior, real owner interleavings, and the verified duplicate-hook regression. Do not add reflection-only owner-count assertions, artificial corruption branches, or tests for SDK reinitialization while other executions are active; the SDK contract explicitly disallows that state. + +### Performance verification + +Before the first source edit, create a disposable benchmark under `/tmp` and record warmed, repeated samples for: + +1. child creation with active Sentry but no parent runtime context; +2. child creation under an active runtime context; +3. an Sentry delivery child; +4. an active-Sentry WebSocket message boundary. + +Run the same script after implementation. The no-parent path must stay within measurement noise or improve from fewer context lookups. The delivery path replaces repeated lookups and cloning with one marker write and one lookup and must not regress beyond noise. The active-child path may add only one retain, one context write, one defer registration, and the matching empty/final release. The message result records the unavoidable absolute price of a RuntimeContext plus Logs and Metrics aggregators, with no disposable SDK Hub or Scope, blocking I/O, or unbounded growth. Investigate any larger cost before review. Delete the disposable benchmark and results after recording the comparison; do not add a maintenance surface solely for this change. + +### Commands and final checks + +- After each new or changed test file: `./vendor/bin/phpunit --no-progress ` from the components worktree root. +- After each coherent Sentry or WebSocket slice: run the affected package test directories. +- At the final checkpoint, run `composer lint:fix`, `composer analyse`, the targeted Sentry, WebSocket Server, and interacting instrumentation tests, `composer test:parallel`, `composer test:testbench`, and `composer test:dogfood`, in that order. +- Inspect `git diff --check`, `git status --short`, Composer manifests, and the final diff for stale imports, direct optional dependencies, unsupported documentation, dead flush code, debug files, and benchmark artifacts. + +## Completion criteria + +- Logs and Trace Metrics are isolated across concurrent supported Hypervel executions and flushed exactly once by their final owner. +- HTTP, queue, scheduled, WebSocket opening/message/closing, nested child, failure, cancellation, and graceful shutdown paths all have explicit tested ownership. +- Execution boundaries pass the existing Hypervel Hub directly, with no disposable SDK Hub or Scope. There is no per-child SDK context construction, parent-lifetime lookup, global mutable request state, or extra active-path configuration branch. +- Inactive Sentry clears the process-global SDK storage at boot and adds no listeners or child hook. Active child overhead is the minimum counted-sharing work, and the delivery fast path skips all application propagation. +- The custom Hub remains current and tested; bootstrap scope data is no longer lost. +- Public middleware/config/facade APIs remain Laravel-shaped, the optional package graph is unchanged, and canonical docs contain no stale unsupported claims. +- Formatting, both PHPStan configurations, focused tests, the full parallel suite, Testbench package-mode tests, dogfood tests, and the performance comparison pass with no temporary or dead files remaining. diff --git a/src/docs/sentry.md b/src/docs/sentry.md index ea71d5757..6248bd117 100644 --- a/src/docs/sentry.md +++ b/src/docs/sentry.md @@ -25,7 +25,7 @@ [Sentry](https://sentry.io) provides error tracking and performance monitoring for your Hypervel application. Hypervel's Sentry integration captures exceptions, logs, requests, database queries, cache operations, queued jobs, notifications, Redis commands, scheduled tasks, and filesystem operations. -The integration is designed for Hypervel's long-running Swoole workers. Request state is isolated between coroutines, while Sentry's HTTP connections are pooled and reused across requests. +The integration is designed for Hypervel's long-running Swoole workers. Sentry state is isolated across requests, queued jobs, scheduled tasks, and WebSocket callbacks, while HTTP connections are pooled and reused across executions. An execution remains active until any application child coroutines it starts have finished. ## Installation @@ -142,10 +142,11 @@ Log records sent through this channel are converted into Sentry events. Exceptio ### Sentry Logs -> [!WARNING] -> Sentry Logs are currently unsupported in Hypervel. The Sentry SDK shares its buffered log records across application executions, so one execution may flush records collected by another. Keep `SENTRY_ENABLE_LOGS` disabled. The regular `sentry` event channel remains fully supported. +The `sentry_logs` channel sends structured records to Sentry Logs. Records are isolated between concurrent requests, queued jobs, scheduled tasks, and WebSocket callbacks, then flushed when that execution finishes. -The `sentry_logs` channel and its configuration remain available so applications can adopt Sentry Logs when the SDK provides isolated runtime contexts. The channel uses `SENTRY_LOG_LEVEL` and falls back directly to your application's `LOG_LEVEL` value. The upstream `SENTRY_LOGS_LEVEL` compatibility alias is not supported. +The channel uses `SENTRY_LOG_LEVEL` and falls back directly to your application's `LOG_LEVEL` value. The upstream `SENTRY_LOGS_LEVEL` compatibility alias is not supported. + +The `SENTRY_ENABLE_LOGS` option is kept for compatibility but is deprecated and no longer turns logs on or off. To disable Sentry Logs, configure `before_send_log` to return `null`. ## Performance Monitoring @@ -209,10 +210,19 @@ This middleware can downsample a transaction that was already sampled by your gl ### Metrics -> [!WARNING] -> Sentry trace metrics are currently unsupported in Hypervel. The Sentry SDK shares its metric aggregators across application executions, so one execution may flush metrics collected by another. This does not affect transaction tracing or spans. +You may record trace metrics using Sentry's `traceMetrics` function: + +```php +use function Sentry\traceMetrics; + +traceMetrics()->count('orders.processed', 1, [ + 'region' => 'us-east', +]); +``` + +Metrics are isolated between concurrent requests, queued jobs, scheduled tasks, and WebSocket callbacks, then flushed when that execution finishes. -Trace metrics are disabled by default. The `SENTRY_ENABLE_METRICS` option remains available, but enabling it is unsupported until the SDK can isolate metric aggregators between application executions. +The `SENTRY_ENABLE_METRICS` option is kept for compatibility but is deprecated and no longer turns metrics on or off. To disable trace metrics, configure `before_send_metric` to return `null`. ### Scheduled Tasks @@ -308,7 +318,7 @@ Spotlight may be used without configuring a Sentry DSN. ## Delivery and Shutdown -Sentry events are sent from detached coroutines using a bounded pool of reusable HTTP transports. Normal requests and queued jobs do not wait for event delivery, and commands use the same non-blocking delivery by default. Sends started outside a coroutine complete before returning so short-lived CLI processes cannot exit while an accepted send is still running. If the pool is exhausted during an exception storm, new telemetry is dropped instead of delaying application work. +Buffered logs and metrics are flushed when their execution finishes. Sentry envelopes are then sent from detached coroutines using a bounded pool of reusable HTTP transports. Requests, queued jobs, scheduled tasks, and WebSocket callbacks do not wait for event delivery, and commands use the same non-blocking delivery by default. Sends started outside a coroutine complete before returning so short-lived CLI processes cannot exit while an accepted send is still running. If the pool is exhausted during an exception storm, new telemetry is dropped instead of delaying application work. Graceful worker shutdown performs a bounded drain after the worker-exit coordinator is released, then closes the transport pool. Delivery during a worker exit is best effort because Swoole may terminate outstanding reactor work after its shutdown deadline. diff --git a/src/docs/websockets.md b/src/docs/websockets.md index 13f8ca483..a32cd10ab 100644 --- a/src/docs/websockets.md +++ b/src/docs/websockets.md @@ -222,9 +222,12 @@ Route::get('/ws/chat', ChatSocket::class) Hypervel dispatches the following events for custom WebSocket connections: -- `Hypervel\WebSocketServer\Events\ConnectionOpened` provides the file descriptor, native request, and server name. -- `Hypervel\WebSocketServer\Events\MessageReceived` provides the file descriptor, native frame, and server name. -- `Hypervel\WebSocketServer\Events\ConnectionClosed` provides the file descriptor, reactor ID, and server name. +- `Hypervel\WebSocketServer\Events\ConnectionOpening` is dispatched before handshake validation and routing. It provides the file descriptor, bridged HTTP request, and server name. +- `Hypervel\WebSocketServer\Events\ConnectionOpened` is dispatched after a successful handshake and before the handler's `onOpen` method. It provides the file descriptor, native request, and server name. +- `Hypervel\WebSocketServer\Events\MessageReceived` is dispatched before the handler's `onMessage` method. It provides the file descriptor, native frame, and server name. +- `Hypervel\WebSocketServer\Events\MessageHandled` is dispatched after the handler's `onMessage` method. It provides the file descriptor, native frame, server name, and any exception raised while handling the message. +- `Hypervel\WebSocketServer\Events\ConnectionClosing` is dispatched before the handler's `onClose` method. It provides the file descriptor, reactor ID, and server name. +- `Hypervel\WebSocketServer\Events\ConnectionClosed` is dispatched after the handler's `onClose` method. It provides the file descriptor, reactor ID, and server name. You may listen for these events using Hypervel's normal [event listeners](/docs/{{version}}/events#registering-events-and-listeners). diff --git a/src/sentry/composer.json b/src/sentry/composer.json index e67ce900c..48c601fc9 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -59,7 +59,7 @@ "nyholm/psr7": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "sentry/sentry": "^4.27", + "sentry/sentry": "dev-master", "symfony/console": "^8.1", "symfony/http-foundation": "^8.1", "symfony/psr-http-message-bridge": "^8.1" diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index 88f094db8..1596dc1d9 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -60,21 +60,16 @@ // Only continue incoming traces when the organization IDs are compatible with this SDK instance. 'strict_trace_continuation' => (bool) env('SENTRY_STRICT_TRACE_CONTINUATION', false), - // Sentry Logs are currently unsupported because the SDK buffers them across executions. - // See https://hypervel.org/docs/sentry#sentry-logs before enabling this option. // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs 'enable_logs' => (bool) env('SENTRY_ENABLE_LOGS', false), - // Trace metrics are currently unsupported because the SDK aggregates them across executions. - // See https://hypervel.org/docs/sentry#metrics before enabling this option. // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics - 'enable_metrics' => (bool) env('SENTRY_ENABLE_METRICS', false), + 'enable_metrics' => (bool) env('SENTRY_ENABLE_METRICS', true), - // This option affects the currently unsupported Sentry Logs feature only. // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold 'log_flush_threshold' => $logFlushThreshold === null ? null : (int) $logFlushThreshold, - // The minimum log level for the currently unsupported `sentry_logs` logging channel. + // The minimum log level sent to Sentry through the `sentry_logs` logging channel. 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('LOG_LEVEL', 'debug')), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii diff --git a/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php b/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php index 774dc2ab2..4e0b507b8 100644 --- a/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php +++ b/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php @@ -6,7 +6,6 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; -use Hypervel\Sentry\Integration; use Sentry\SentrySdk; use Sentry\State\Scope; use Sentry\Tracing\Span; @@ -99,7 +98,6 @@ protected function maybePopScope(): void return; } - Integration::flushEvents(); SentrySdk::getCurrentHub()->popScope(); CoroutineContext::set($this->contextKey('scope_count'), $count - 1); diff --git a/src/sentry/src/Features/ConsoleSchedulingFeature.php b/src/sentry/src/Features/ConsoleSchedulingFeature.php index c1ce7460a..72142c95f 100644 --- a/src/sentry/src/Features/ConsoleSchedulingFeature.php +++ b/src/sentry/src/Features/ConsoleSchedulingFeature.php @@ -12,7 +12,6 @@ use Hypervel\Console\Scheduling\Event as SchedulingEvent; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; -use Hypervel\Sentry\Integration; use Hypervel\Support\Str; use RuntimeException; use Sentry\CheckIn; @@ -163,10 +162,7 @@ public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void ? SpanStatus::ok() : SpanStatus::internalError(); - // Only the terminal event that owns the tracked span flushes buffered logs and trace metrics. - if ($this->maybeFinishSpan($status) !== null) { - Integration::flushEvents(); - } + $this->maybeFinishSpan($status); } /** @@ -174,9 +170,7 @@ public function handleScheduledTaskFinished(ScheduledTaskFinished $event): void */ public function handleScheduledTaskFailed(): void { - if ($this->maybeFinishSpan(SpanStatus::internalError()) !== null) { - Integration::flushEvents(); - } + $this->maybeFinishSpan(SpanStatus::internalError()); } private function startCheckIn( diff --git a/src/sentry/src/Features/QueueFeature.php b/src/sentry/src/Features/QueueFeature.php index 127bb5cc7..607bb927b 100644 --- a/src/sentry/src/Features/QueueFeature.php +++ b/src/sentry/src/Features/QueueFeature.php @@ -256,8 +256,6 @@ public function handleWorkerStoppingQueueEvent(WorkerStopping $event): void public function handleJobExceptionOccurredQueueEvent(JobExceptionOccurred $event): void { $this->maybeFinishSpan(SpanStatus::internalError()); - - Integration::flushEvents(); } private function normalizeQueueName(?string $queue): string diff --git a/src/sentry/src/Http/FlushEventsMiddleware.php b/src/sentry/src/Http/FlushEventsMiddleware.php index 69fb26c92..de2095675 100644 --- a/src/sentry/src/Http/FlushEventsMiddleware.php +++ b/src/sentry/src/Http/FlushEventsMiddleware.php @@ -5,21 +5,29 @@ namespace Hypervel\Sentry\Http; use Closure; -use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; -use Hypervel\Sentry\Integration; +use Hypervel\Sentry\State\RuntimeContextBoundary; use Symfony\Component\HttpFoundation\Response; +/** + * Open the outer request context whose deferred end flushes buffered telemetry. + */ class FlushEventsMiddleware { + /** + * Create a Sentry runtime context middleware. + */ + public function __construct( + protected RuntimeContextBoundary $runtimeContextBoundary, + ) { + } + /** * Handle an incoming request. */ public function handle(Request $request, Closure $next): Response { - Coroutine::defer(static function (): void { - Integration::flushEvents(); - }); + $this->runtimeContextBoundary->start(); return $next($request); } diff --git a/src/sentry/src/Integration.php b/src/sentry/src/Integration.php index 57e9cf973..8fd1f26a9 100644 --- a/src/sentry/src/Integration.php +++ b/src/sentry/src/Integration.php @@ -14,8 +14,6 @@ use Sentry\EventId; use Sentry\ExceptionMechanism; use Sentry\Integration\IntegrationInterface; -use Sentry\Logs\Logs; -use Sentry\Metrics\TraceMetrics; use Sentry\SentrySdk; use Sentry\State\Scope; use Sentry\Tracing\TransactionSource; @@ -114,20 +112,22 @@ public static function setTransaction(?string $transaction): void CoroutineContext::set(self::CONTEXT_TRANSACTION_KEY, $transaction); } - /** - * Flush buffered events without waiting for delivery. - */ - public static function flushEvents(): void - { - self::flush(null, false); - } - /** * Flush buffered events and wait for the captured delivery generation. */ public static function drainEvents(?int $timeout = null): Result { - return self::flush($timeout, true); + SentrySdk::flush(); + + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client === null) { + return new Result(ResultStatus::success()); + } + + $timeout = max(1, $timeout ?? (int) ceil($client->getOptions()->getHttpTimeout())); + + return $client->flush($timeout); } /** @@ -222,27 +222,6 @@ private static function escapeMetaTagContent(string $value): string return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } - /** - * Flush buffered SDK telemetry before flushing its client transport. - */ - private static function flush(?int $timeout, bool $drain): Result - { - $client = SentrySdk::getCurrentHub()->getClient(); - - if ($client === null) { - return new Result(ResultStatus::success()); - } - - if ($drain) { - $timeout = max(1, $timeout ?? (int) ceil($client->getOptions()->getHttpTimeout())); - } - - Logs::getInstance()->flush(); - TraceMetrics::getInstance()->flush(); - - return $client->flush($timeout); - } - /** * Try to make an educated guess if the call came from the `report` helper. * diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 77c7ee263..61a0130d0 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -4,7 +4,9 @@ namespace Hypervel\Sentry; +use ArrayObject; use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Events\Dispatcher; @@ -15,6 +17,7 @@ use Hypervel\Foundation\Console\AboutCommand; use Hypervel\Http\Request; use Hypervel\ObjectPool\PoolOptions; +use Hypervel\Queue\Events\JobProcessing; use Hypervel\Routing\Contracts\CallableDispatcher; use Hypervel\Routing\Contracts\ControllerDispatcher; use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect; @@ -27,6 +30,8 @@ use Hypervel\Sentry\Http\SetRequestIpMiddleware; use Hypervel\Sentry\Integration\ContextIntegration; use Hypervel\Sentry\Integration\ExceptionContextIntegration; +use Hypervel\Sentry\State\CoroutineRuntimeContextStorage; +use Hypervel\Sentry\State\RuntimeContextBoundary; use Hypervel\Sentry\Tracing\BacktraceHelper; use Hypervel\Sentry\Tracing\EventHandler as TracingEventHandler; use Hypervel\Sentry\Tracing\Middleware as TracingMiddleware; @@ -38,6 +43,9 @@ use Hypervel\Support\ServiceProvider; use Hypervel\View\Engines\EngineResolver; use Hypervel\View\Factory as ViewFactory; +use Hypervel\WebSocketServer\Events\ConnectionClosing; +use Hypervel\WebSocketServer\Events\ConnectionOpening; +use Hypervel\WebSocketServer\Events\MessageReceived; use InvalidArgumentException; use LogicException; use Psr\Log\LoggerInterface; @@ -45,11 +53,11 @@ use Sentry\ClientBuilder; use Sentry\Integration as SdkIntegration; use Sentry\Logger\DebugFileLogger; -use Sentry\Logs\Logs; use Sentry\SentrySdk; use Sentry\Serializer\RepresentationSerializer; use Sentry\State\HubInterface; use Sentry\State\Layer; +use Sentry\State\Scope; use Throwable; class SentryServiceProvider extends ServiceProvider @@ -90,18 +98,35 @@ class SentryServiceProvider extends ServiceProvider */ public function boot(): void { + $active = $this->isActive(); + $runtimeContextStorage = $active + ? $this->app->make(CoroutineRuntimeContextStorage::class) + : null; + + SentrySdk::setRuntimeContextStorage($runtimeContextStorage); + // Eagerly resolve the Hub so SentrySdk has it available globally $this->app->make(HubInterface::class); - $this->bootFeatures(); + if ($runtimeContextStorage !== null) { + $this->registerRuntimeContextBoundaries(); + $this->registerCoroutineContextPropagation($runtimeContextStorage); + + $this->app->terminating(static function () use ($runtimeContextStorage): void { + if ($runtimeContextStorage->get() === null) { + SentrySdk::flush(); + } + }); + } + + $this->bootFeatures($active); // Only register event/middleware/tracing if a DSN is set or Spotlight is enabled. // No events can be sent without a DSN or Spotlight. - if ($this->isActive()) { + if ($active) { $this->bindEvents(); $this->registerMiddleware(); $this->bootTracing(); - $this->registerCoroutineContextPropagation(); } if ($this->app->runningInConsole()) { @@ -267,7 +292,7 @@ protected function configureAndRegisterClient(): void return $integrations; }); - $hub = new Hub($clientBuilder->getClient()); + $hub = new Hub($clientBuilder->getClient(), $this->cloneCurrentHubScope()); SentrySdk::setCurrentHub($hub); @@ -285,6 +310,26 @@ protected function configureAndRegisterClient(): void }); } + /** + * Clone the scope configured before the Sentry client was resolved. + */ + private function cloneCurrentHubScope(): ?Scope + { + $currentHub = SentrySdk::getCurrentHub(); + + if ($currentHub->getClient() !== null) { + return null; + } + + $clonedScope = null; + + $currentHub->configureScope(static function (Scope $scope) use (&$clonedScope): void { + $clonedScope = clone $scope; + }); + + return $clonedScope; + } + /** * Normalize the options supported by Sentry's standalone transport pool. */ @@ -325,11 +370,31 @@ protected function bindEvents(): void if ($userConfig['send_default_pii'] === true) { $handler->subscribeAuthEvents($dispatcher); } + } catch (BindingResolutionException) { + // If we cannot resolve the event dispatcher we also cannot listen to events + } + } - if ($userConfig['enable_logs'] === true) { - $this->app->terminating(static function () { - Logs::getInstance()->flush(); - }); + /** + * Start runtime contexts before execution-specific Sentry listeners run. + */ + protected function registerRuntimeContextBoundaries(): void + { + try { + /** @var Dispatcher $dispatcher */ + $dispatcher = $this->app->make('events'); + $listener = function (): void { + $this->app->make(RuntimeContextBoundary::class)->start(); + }; + + foreach ([ + JobProcessing::class, + ScheduledTaskStarting::class, + ConnectionOpening::class, + MessageReceived::class, + ConnectionClosing::class, + ] as $event) { + $dispatcher->listen($event, $listener); } } catch (BindingResolutionException) { // If we cannot resolve the event dispatcher we also cannot listen to events @@ -347,7 +412,7 @@ protected function registerMiddleware(): void $httpKernel = $this->app->make(HttpKernelInterface::class); - // The second prepend makes Flush outermost, so its defer runs after tracing and feature finalizers. + // The second prepend makes the runtime context outermost, so it ends after tracing and feature finalizers. $httpKernel->prependMiddleware(TracingMiddleware::class); $httpKernel->prependMiddleware(FlushEventsMiddleware::class); @@ -479,31 +544,48 @@ private function decorateRoutingDispatchers(): void * * Copy isolated Sentry scope and request values into child coroutines. */ - protected function registerCoroutineContextPropagation(): void - { - Coroutine::afterCreated(function (): void { - $parentId = Coroutine::parentId(); - $stack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY) - ?? CoroutineContext::get(Hub::CONTEXT_STACK_KEY, null, $parentId); + protected function registerCoroutineContextPropagation( + CoroutineRuntimeContextStorage $runtimeContextStorage, + ): void { + Coroutine::afterCreated(static function () use ($runtimeContextStorage): void { + /** @var ArrayObject $context */ + $context = CoroutineContext::getContainer(); + + if (isset($context[HttpPoolTransport::DELIVERY_CONTEXT_KEY])) { + return; + } + + /** @var null|ArrayObject $parentContext */ + $parentContext = CoroutineContext::getContainer(Coroutine::parentId()); + + /** @var null|list $stack */ + $stack = $context[Hub::CONTEXT_STACK_KEY] + ?? $parentContext[Hub::CONTEXT_STACK_KEY] + ?? null; if ($stack !== null) { - CoroutineContext::set( - Hub::CONTEXT_STACK_KEY, - array_map( - static fn (Layer $layer): Layer => new Layer( - $layer->getClient(), - clone $layer->getScope(), - ), - $stack, + $context[Hub::CONTEXT_STACK_KEY] = array_map( + static fn (Layer $layer): Layer => new Layer( + $layer->getClient(), + clone $layer->getScope(), ), + $stack, ); } - $request = CoroutineContext::get(Request::class) - ?? CoroutineContext::get(Request::class, null, $parentId); + /** @var ?Request $request */ + $request = $context[Request::class] + ?? $parentContext[Request::class] + ?? null; if ($request !== null) { - CoroutineContext::set(Request::class, clone $request); + $context[Request::class] = clone $request; + } + + if ($runtimeContextStorage->inheritFrom($context, $parentContext)) { + Coroutine::defer(static function (): void { + SentrySdk::endContext(); + }); } }); } @@ -530,10 +612,8 @@ protected function registerFeatures(): void /** * Boot all features. */ - protected function bootFeatures(): void + protected function bootFeatures(bool $active): void { - $bootActive = $this->isActive(); - $features = $this->app->make('config')->array(static::$abstract . '.features'); foreach ($features as $feature) { @@ -541,13 +621,13 @@ protected function bootFeatures(): void /** @var Feature $featureInstance */ $featureInstance = $this->app->make($feature); - $bootActive + $active ? $featureInstance->boot() : $featureInstance->bootInactive(); } catch (Throwable $exception) { $this->reportFeatureFailure( $feature, - $bootActive ? 'boot' : 'bootInactive', + $active ? 'boot' : 'bootInactive', $exception, ); } diff --git a/src/sentry/src/State/CoroutineRuntimeContextStorage.php b/src/sentry/src/State/CoroutineRuntimeContextStorage.php new file mode 100644 index 000000000..c1c989878 --- /dev/null +++ b/src/sentry/src/State/CoroutineRuntimeContextStorage.php @@ -0,0 +1,81 @@ +getRuntimeContext(); + } + + /** + * Store a runtime context for the current coroutine. + */ + public function set(RuntimeContext $runtimeContext): void + { + CoroutineContext::set( + self::CONTEXT_KEY, + new SharedRuntimeContext($runtimeContext), + ); + } + + /** + * Release the current coroutine's runtime context. + * + * Only the final owner returns the context so the SDK flushes it once. + */ + public function remove(): ?RuntimeContext + { + /** @var ?SharedRuntimeContext $sharedRuntimeContext */ + $sharedRuntimeContext = CoroutineContext::get(self::CONTEXT_KEY); + + if ($sharedRuntimeContext === null) { + return null; + } + + CoroutineContext::forget(self::CONTEXT_KEY); + + return $sharedRuntimeContext->release(); + } + + /** + * Share a parent's runtime context with a child coroutine. + * + * @param ArrayObject $context + * @param null|ArrayObject $parentContext + */ + public function inheritFrom(ArrayObject $context, ?ArrayObject $parentContext): bool + { + if (isset($context[self::CONTEXT_KEY]) + || $parentContext === null + || ! isset($parentContext[self::CONTEXT_KEY])) { + return false; + } + + /** @var SharedRuntimeContext $sharedRuntimeContext */ + $sharedRuntimeContext = $parentContext[self::CONTEXT_KEY]; + $sharedRuntimeContext->retain(); + $context[self::CONTEXT_KEY] = $sharedRuntimeContext; + + return true; + } +} diff --git a/src/sentry/src/State/RuntimeContextBoundary.php b/src/sentry/src/State/RuntimeContextBoundary.php new file mode 100644 index 000000000..390e5aa0e --- /dev/null +++ b/src/sentry/src/State/RuntimeContextBoundary.php @@ -0,0 +1,41 @@ +runtimeContextStorage->get() !== null) { + return; + } + + SentrySdk::startContext($this->hub); + Coroutine::defer(static function (): void { + SentrySdk::endContext(); + }); + } +} diff --git a/src/sentry/src/State/SharedRuntimeContext.php b/src/sentry/src/State/SharedRuntimeContext.php new file mode 100644 index 000000000..268f64c5c --- /dev/null +++ b/src/sentry/src/State/SharedRuntimeContext.php @@ -0,0 +1,50 @@ +runtimeContext; + } + + /** + * Retain another owner. + */ + public function retain(): void + { + ++$this->owners; + } + + /** + * Release an owner and return the context after its final release. + */ + public function release(): ?RuntimeContext + { + --$this->owners; + + return $this->owners === 0 ? $this->runtimeContext : null; + } +} diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index d507e2cb7..8a22e1283 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -188,7 +188,7 @@ private function startTransaction(Request $request): void $this->transaction = $transaction; if ($this->continueAfterResponse) { - // This runs before the earlier outer flush defer and after later + // This runs before the outer runtime context ends and after later // after-response work because coroutine defers are LIFO. Coroutine::defer(function (): void { $this->finishTransaction(); diff --git a/src/sentry/src/Transport/HttpPoolTransport.php b/src/sentry/src/Transport/HttpPoolTransport.php index 673b6bdd5..703c58e80 100644 --- a/src/sentry/src/Transport/HttpPoolTransport.php +++ b/src/sentry/src/Transport/HttpPoolTransport.php @@ -5,6 +5,7 @@ namespace Hypervel\Sentry\Transport; use Closure; +use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\WaitGroup; use RuntimeException; @@ -21,6 +22,8 @@ class HttpPoolTransport implements TransportInterface { + public const string DELIVERY_CONTEXT_KEY = '__sentry.delivery'; + protected WaitGroup $group; public function __construct(protected Pool $pool) @@ -60,6 +63,7 @@ public function send(Event $event): Result $wrapper = function (Closure $run) use ($group, $transport, &$started, &$discard): void { try { $started = true; + CoroutineContext::set(self::DELIVERY_CONTEXT_KEY, true); $run(); } finally { try { diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index caa28d064..a5f3ae291 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -463,6 +463,8 @@ protected function flushScoutState(): void */ protected function flushSentryState(): void { + $this->callIfExists(\Sentry\SentrySdk::class, 'setRuntimeContextStorage', null); + $this->callIfExists(\Sentry\SentrySdk::class, 'init'); $this->callIfExists(\Hypervel\Sentry\Http\HypervelRequestFetcher::class, 'flushState'); $this->callIfExists(\Hypervel\Sentry\Tracing\Middleware::class, 'flushState'); } diff --git a/src/websocket-server/src/Events/ConnectionClosing.php b/src/websocket-server/src/Events/ConnectionClosing.php new file mode 100644 index 000000000..83d247dfb --- /dev/null +++ b/src/websocket-server/src/Events/ConnectionClosing.php @@ -0,0 +1,18 @@ +event?->hasListeners(ConnectionOpening::class)) { + $this->event->dispatch(new ConnectionOpening($fd, $httpRequest, $this->serverName)); + } + if ($this->event?->hasListeners(RequestReceived::class)) { $this->event->dispatch(new RequestReceived( request: $httpRequest, @@ -316,6 +322,16 @@ public function onClose(SwooleServer $server, int $fd, int $reactorId): void return; } + try { + if ($this->event?->hasListeners(ConnectionClosing::class)) { + $this->event->dispatch(new ConnectionClosing($fd, $reactorId, $this->serverName)); + } + } catch (CanceledException) { + return; + } catch (Throwable $throwable) { + $this->reportCallbackFailure($throwable); + } + try { $this->logger->debug(sprintf('WebSocket: fd[%d] closed.', $fd)); } catch (CanceledException) { diff --git a/tests/Integration/Horizon/Feature/SupervisorCommandTest.php b/tests/Integration/Horizon/Feature/SupervisorCommandTest.php index 688ce21f5..2d5655d30 100644 --- a/tests/Integration/Horizon/Feature/SupervisorCommandTest.php +++ b/tests/Integration/Horizon/Feature/SupervisorCommandTest.php @@ -9,6 +9,7 @@ use Hypervel\Horizon\SupervisorFactory; use Hypervel\Tests\Integration\Horizon\Feature\Fixtures\FakeSupervisorFactory; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; class SupervisorCommandTest extends IntegrationTestCase { @@ -69,12 +70,14 @@ public function testSupervisorCommandCanStartPausedSupervisors(): void $this->assertFalse($factory->supervisor->working); } + #[RunInSeparateProcess] public function testSupervisorCommandCanSetProcessNiceness(): void { + $initialPriority = pcntl_getpriority(); $this->app->instance(SupervisorFactory::class, new FakeSupervisorFactory); $this->artisan('horizon:supervisor', ['--nice' => 10] + static::OPTIONS); - $this->assertSame(10, pcntl_getpriority()); + $this->assertSame($initialPriority + 10, pcntl_getpriority()); } public function testSupervisorCommandPreservesZeroQueue(): void diff --git a/tests/Sentry/ConfigTest.php b/tests/Sentry/ConfigTest.php index 74a2e3e10..c9a5f834c 100644 --- a/tests/Sentry/ConfigTest.php +++ b/tests/Sentry/ConfigTest.php @@ -106,13 +106,13 @@ public function testStorageTelemetryIsEnabledByDefault(): void $this->assertTrue($config['tracing']['storage']); } - public function testTraceMetricsAreDisabledByDefault(): void + public function testTraceMetricsAreEnabledByDefault(): void { $config = $this->withEnvironmentValues([ 'SENTRY_ENABLE_METRICS' => null, ], fn (): array => $this->sentryConfig()); - $this->assertFalse($config['enable_metrics']); + $this->assertTrue($config['enable_metrics']); } public function testBooleanEnvironmentValuesAreNormalized(): void diff --git a/tests/Sentry/CoroutineContextPropagationTest.php b/tests/Sentry/CoroutineContextPropagationTest.php index 2d1b79e8c..43eb365e2 100644 --- a/tests/Sentry/CoroutineContextPropagationTest.php +++ b/tests/Sentry/CoroutineContextPropagationTest.php @@ -4,15 +4,23 @@ namespace Hypervel\Tests\Sentry; +use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Sentry\Hub; +use Hypervel\Sentry\State\CoroutineRuntimeContextStorage; +use Hypervel\Sentry\Transport\HttpPoolTransport; use Sentry\Event; +use Sentry\EventType; +use Sentry\SentrySdk; use Sentry\State\Layer; use Sentry\State\Scope; use Swoole\Coroutine\Channel; +use function Sentry\logger; +use function Sentry\traceMetrics; + class CoroutineContextPropagationTest extends SentryTestCase { public function testOrdinaryChildCoroutinesCloneParentSentryState(): void @@ -139,6 +147,162 @@ public function testChildWithoutParentRequestContextGetsNull(): void $this->assertSame('missing', $result->pop(1.0)); } + public function testCreateSharesTheParentRuntimeContext(): void + { + $this->assertChildSharesActiveRuntimeContext( + static fn (callable $callback): int => Coroutine::create($callback), + ); + } + + public function testForkSharesTheParentRuntimeContext(): void + { + $this->assertChildSharesActiveRuntimeContext( + static fn (callable $callback): int => Coroutine::fork($callback), + ); + } + + public function testSelectiveForkSharesTheParentRuntimeContext(): void + { + $this->assertChildSharesActiveRuntimeContext( + static fn (callable $callback): int => Coroutine::fork($callback, ['selected']), + ); + } + + public function testGrandchildRetainsRuntimeContextAfterIntermediateParentExits(): void + { + SentrySdk::startContext($this->getSentryHubFromContainer()); + $runtimeContext = SentrySdk::getCurrentRuntimeContext(); + $grandchildReady = new Channel(1); + $releaseGrandchild = new Channel(1); + $grandchildResult = new Channel(1); + $grandchildId = new Channel(1); + + $childId = Coroutine::create(static function () use ( + $grandchildReady, + $releaseGrandchild, + $grandchildResult, + $grandchildId, + ): void { + $grandchildId->push(Coroutine::create(static function () use ( + $grandchildReady, + $releaseGrandchild, + $grandchildResult, + ): void { + $grandchildReady->push(true); + $releaseGrandchild->pop(); + $grandchildResult->push(SentrySdk::getCurrentRuntimeContext()); + })); + }); + + $this->assertTrue($grandchildReady->pop(1.0)); + Coroutine::join([$childId]); + + $releaseGrandchild->push(true); + + $this->assertSame($runtimeContext, $grandchildResult->pop(1.0)); + Coroutine::join([$grandchildId->pop(1.0)]); + + SentrySdk::endContext(); + } + + public function testSharedTelemetryFlushesOnceWhenChildExitsFirst(): void + { + SentrySdk::startContext($this->getSentryHubFromContainer()); + logger()->info('parent log'); + traceMetrics()->count('parent.metric', 1); + + $childId = Coroutine::create(static function (): void { + logger()->info('child log'); + traceMetrics()->count('child.metric', 1); + }); + + Coroutine::join([$childId]); + $this->assertSame(0, $this->countCapturedEvents(EventType::logs())); + $this->assertSame(0, $this->countCapturedEvents(EventType::metrics())); + + SentrySdk::endContext(); + + $this->assertSharedTelemetryFlushedOnce(); + } + + public function testSharedTelemetryFlushesOnceWhenParentExitsFirst(): void + { + SentrySdk::startContext($this->getSentryHubFromContainer()); + logger()->info('parent log'); + traceMetrics()->count('parent.metric', 1); + $childReady = new Channel(1); + $releaseChild = new Channel(1); + + $childId = Coroutine::fork(static function () use ($childReady, $releaseChild): void { + logger()->info('child log'); + traceMetrics()->count('child.metric', 1); + $childReady->push(true); + $releaseChild->pop(); + }); + + $this->assertTrue($childReady->pop(1.0)); + + SentrySdk::endContext(); + + $this->assertSame(0, $this->countCapturedEvents(EventType::logs())); + $this->assertSame(0, $this->countCapturedEvents(EventType::metrics())); + + $releaseChild->push(true); + Coroutine::join([$childId]); + + $this->assertSharedTelemetryFlushedOnce(); + } + + public function testDuplicatePropagationHooksRetainChildOnce(): void + { + $this->resetApplicationWithConfig([]); + SentrySdk::startContext($this->getSentryHubFromContainer()); + logger()->info('shared log'); + traceMetrics()->count('shared.metric', 1); + + $childId = Coroutine::create(static function (): void { + }); + + Coroutine::join([$childId]); + + SentrySdk::endContext(); + + $this->assertSame(1, $this->countCapturedEvents(EventType::logs())); + $this->assertSame(1, $this->countCapturedEvents(EventType::metrics())); + } + + public function testDeliveryChildDoesNotInheritApplicationContext(): void + { + $hub = $this->getSentryHubFromContainer(); + $hub->pushScope(); + CoroutineContext::set(Request::class, Request::create('/delivery')); + SentrySdk::startContext($hub); + $result = new Channel(1); + + $childId = Coroutine::createOwned( + static function () use ($result): void { + $result->push([ + CoroutineContext::get(Hub::CONTEXT_STACK_KEY), + CoroutineContext::get(Request::class), + app(CoroutineRuntimeContextStorage::class)->get(), + ]); + }, + static function (Closure $run): void { + CoroutineContext::set(HttpPoolTransport::DELIVERY_CONTEXT_KEY, true); + $run(); + }, + ); + + [$stack, $request, $runtimeContext] = $result->pop(1.0); + + $this->assertNull($stack); + $this->assertNull($request); + $this->assertNull($runtimeContext); + Coroutine::join([$childId]); + + SentrySdk::endContext(); + } + /** * Get the tags applied by the current Hub scope. * @@ -153,4 +317,47 @@ private function scopeTags(Hub $hub): array return $event->getTags(); } + + /** + * Assert a child creation API shares the active runtime context. + * + * @param callable(callable(): void): int $createChild + */ + private function assertChildSharesActiveRuntimeContext(callable $createChild): void + { + SentrySdk::startContext($this->getSentryHubFromContainer()); + $runtimeContext = SentrySdk::getCurrentRuntimeContext(); + $result = new Channel(1); + + $childId = $createChild(static function () use ($result): void { + $result->push(SentrySdk::getCurrentRuntimeContext()); + }); + + $this->assertSame($runtimeContext, $result->pop(1.0)); + Coroutine::join([$childId]); + + SentrySdk::endContext(); + } + + /** + * Assert the shared telemetry buffers produced one event each. + */ + private function assertSharedTelemetryFlushedOnce(): void + { + $logEvents = $this->getCapturedSentryEventsOfType(EventType::logs()); + $metricEvents = $this->getCapturedSentryEventsOfType(EventType::metrics()); + + $this->assertCount(1, $logEvents); + $this->assertCount(2, $logEvents[0][0]->getLogs()); + $this->assertCount(1, $metricEvents); + $this->assertCount(2, $metricEvents[0][0]->getMetrics()); + } + + /** + * Count captured Sentry events of a type. + */ + private function countCapturedEvents(EventType $eventType): int + { + return count($this->getCapturedSentryEventsOfType($eventType)); + } } diff --git a/tests/Sentry/EventHandlerTest.php b/tests/Sentry/EventHandlerTest.php index 3987cf4f3..bf2a114fa 100644 --- a/tests/Sentry/EventHandlerTest.php +++ b/tests/Sentry/EventHandlerTest.php @@ -57,7 +57,16 @@ public function testWorkerExitListenerReturnsBeforeCoordinatorReleaseThenDrainsA }); $client = m::mock(Client::class); $client->shouldReceive('getOptions')->once()->andReturn(new Options(['http_timeout' => 0.1])); - $client->shouldReceive('flush')->once()->with(1)->andReturn(new Result(ResultStatus::success())); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); + $client->shouldReceive('flush') + ->once() + ->with(1) + ->ordered() + ->andReturn(new Result(ResultStatus::success())); $client->shouldReceive('getTransport')->once()->andReturn($transport); $previousHub = SentrySdk::getCurrentHub(); SentrySdk::setCurrentHub(new Hub($client)); @@ -92,9 +101,15 @@ public function testWorkerExitClosesTheTransportPoolWhenFlushFails(): void }); $client = m::mock(Client::class); $client->shouldReceive('getOptions')->once()->andReturn(new Options(['http_timeout' => 0.1])); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); $client->shouldReceive('flush') ->once() ->with(1) + ->ordered() ->andThrow($flushException); $client->shouldReceive('getTransport') ->once() diff --git a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php index 9b358a6c4..fb7de9f85 100644 --- a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php +++ b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -361,13 +361,10 @@ public function testCheckInStateIsCleanedUpAfterTaskCompletes(): void */ protected function getCapturedTransactions(): array { - return array_values(array_map( + return array_map( static fn (array $captured): SentryEvent => $captured[0], - array_filter( - $this->getCapturedSentryEvents(), - static fn (array $captured): bool => $captured[0]->getType() === EventType::transaction(), - ), - )); + $this->getCapturedSentryEventsOfType(EventType::transaction()), + ); } protected function getScheduler(): Schedule diff --git a/tests/Sentry/Features/LogLogsIntegrationTest.php b/tests/Sentry/Features/LogLogsIntegrationTest.php index a17ba1b1b..f06d292df 100644 --- a/tests/Sentry/Features/LogLogsIntegrationTest.php +++ b/tests/Sentry/Features/LogLogsIntegrationTest.php @@ -28,8 +28,6 @@ protected function defineEnvironment(ApplicationContract $app): void parent::defineEnvironment($app); tap($app->make('config'), static function (Repository $config) { - $config->set('sentry.enable_logs', true); - $config->set('logging.channels.sentry_logs', [ 'driver' => 'sentry_logs', ]); @@ -130,9 +128,7 @@ public function testLogChannelFlushesImmediatelyWhenThresholdIsReached(): void $this->assertCount(0, logger()->aggregator()->all()); - $logEvents = array_values(array_filter($this->getCapturedSentryEvents(), static function (array $event): bool { - return $event[0]->getType() === EventType::logs(); - })); + $logEvents = $this->getCapturedSentryEventsOfType(EventType::logs()); $this->assertCount(1, $logEvents); $this->assertCount(2, $logEvents[0][0]->getLogs()); @@ -158,9 +154,7 @@ public function testLogChannelDoesNotFlushImmediatelyWhenThresholdIsNull(): void $this->assertCount(2, $bufferedLogs); - $logEvents = array_values(array_filter($this->getCapturedSentryEvents(), static function (array $event): bool { - return $event[0]->getType() === EventType::logs(); - })); + $logEvents = $this->getCapturedSentryEventsOfType(EventType::logs()); $this->assertCount(0, $logEvents); diff --git a/tests/Sentry/Features/RedisIntegrationTest.php b/tests/Sentry/Features/RedisIntegrationTest.php index 6029cc675..fd4cfbec5 100644 --- a/tests/Sentry/Features/RedisIntegrationTest.php +++ b/tests/Sentry/Features/RedisIntegrationTest.php @@ -23,6 +23,7 @@ use Hypervel\Tests\Sentry\SentryTestCase; use Mockery as m; use Sentry\SentrySdk; +use Sentry\State\Hub; use Sentry\State\HubInterface; class RedisIntegrationTest extends SentryTestCase @@ -337,7 +338,7 @@ public function testRedisCommandWithDifferentConfiguration(): void public function testRedisFeatureWorksAfterReplacingStaleGlobalHub(): void { - $staleHub = m::mock(HubInterface::class); + $staleHub = new Hub; SentrySdk::setCurrentHub($staleHub); $this->reloadApplication(); diff --git a/tests/Sentry/FlushLifecycleTest.php b/tests/Sentry/FlushLifecycleTest.php index 0a87bfa32..d29c05edb 100644 --- a/tests/Sentry/FlushLifecycleTest.php +++ b/tests/Sentry/FlushLifecycleTest.php @@ -20,6 +20,7 @@ use Hypervel\Sentry\Features\QueueFeature; use Hypervel\Sentry\Integration; use Hypervel\Sentry\SentryConfig; +use Hypervel\Sentry\State\CoroutineRuntimeContextStorage; use Hypervel\Tests\TestCase; use Mockery as m; use Sentry\ClientInterface; @@ -38,15 +39,12 @@ class FlushLifecycleTest extends TestCase { - public function testFlushPublishesBufferedTelemetryBeforeFlushingTheTransport(): void + public function testDrainPublishesBufferedTelemetryBeforeTheBoundedWait(): void { $client = m::mock(ClientInterface::class); $client->shouldReceive('getOptions') ->times(3) - ->andReturn(new Options([ - 'enable_logs' => true, - 'enable_metrics' => true, - ])); + ->andReturn(new Options); $client->shouldReceive('captureEvent') ->once() ->with( @@ -67,7 +65,12 @@ public function testFlushPublishesBufferedTelemetryBeforeFlushingTheTransport(): ->andReturn(null); $client->shouldReceive('flush') ->once() - ->with(null) + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); + $client->shouldReceive('flush') + ->once() + ->with(1) ->ordered() ->andReturn(new Result(ResultStatus::success())); @@ -75,7 +78,7 @@ public function testFlushPublishesBufferedTelemetryBeforeFlushingTheTransport(): Logs::getInstance()->info('Buffered log'); TraceMetrics::getInstance()->count('buffered.metric', 1); - Integration::flushEvents(); + Integration::drainEvents(1); }); } @@ -85,9 +88,15 @@ public function testDrainDerivesAPositiveTimeoutFromTheClient(): void $client->shouldReceive('getOptions') ->once() ->andReturn(new Options(['http_timeout' => 2.2])); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); $client->shouldReceive('flush') ->once() ->with(3) + ->ordered() ->andReturn(new Result(ResultStatus::success())); $result = $this->withHub( @@ -103,9 +112,15 @@ public function testDrainNormalizesAnExplicitNonPositiveTimeout(): void $client = m::mock(ClientInterface::class); $client->shouldReceive('getOptions') ->never(); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); $client->shouldReceive('flush') ->once() ->with(1) + ->ordered() ->andReturn(new Result(ResultStatus::success())); $result = $this->withHub( @@ -132,9 +147,15 @@ public function testGracefulQueueWorkerStoppingPerformsABoundedDrain(): void $client->shouldReceive('getOptions') ->once() ->andReturn(new Options(['http_timeout' => 1.2])); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->ordered() + ->andReturn(new Result(ResultStatus::success())); $client->shouldReceive('flush') ->once() ->with(2) + ->ordered() ->andReturn(new Result(ResultStatus::success())); $feature = new QueueFeature(m::mock(Container::class)); @@ -161,13 +182,10 @@ public function testImmediateAndMemoryLimitQueueStopsDoNotDrain(): void }); } - public function testConsoleCompletionFlushesBufferedEventsWithoutABoundedDrain(): void + public function testConsoleCompletionLeavesGlobalTelemetryForApplicationTermination(): void { $client = m::mock(ClientInterface::class); - $client->shouldReceive('flush') - ->once() - ->with(null) - ->andReturn(new Result(ResultStatus::success())); + $client->shouldNotReceive('flush'); $client->shouldReceive('getIntegration') ->once() ->with(Integration::class) @@ -197,9 +215,9 @@ public function testConsoleCompletionFlushesBufferedEventsWithoutABoundedDrain() }); } - public function testScheduledTaskCompletionFlushesBufferedEventsOnce(): void + public function testScheduledTaskCompletionFlushesAtExecutionEnd(): void { - $this->assertScheduledTaskFlushesOnce(static function ( + $this->assertScheduledTaskFlushesAtExecutionEnd(static function ( ConsoleSchedulingFeature $feature, ScheduledEvent $event ): void { @@ -207,16 +225,16 @@ public function testScheduledTaskCompletionFlushesBufferedEventsOnce(): void }); } - public function testScheduledTaskFailureFlushesBufferedEventsOnce(): void + public function testScheduledTaskFailureFlushesAtExecutionEnd(): void { - $this->assertScheduledTaskFlushesOnce(static function (ConsoleSchedulingFeature $feature): void { + $this->assertScheduledTaskFlushesAtExecutionEnd(static function (ConsoleSchedulingFeature $feature): void { $feature->handleScheduledTaskFailed(); }); } public function testDuplicateScheduledTaskCompletionDoesNotFlushAgain(): void { - $this->assertScheduledTaskFlushesOnce(static function ( + $this->assertScheduledTaskFlushesAtExecutionEnd(static function ( ConsoleSchedulingFeature $feature, ScheduledEvent $event ): void { @@ -229,7 +247,7 @@ public function testDuplicateScheduledTaskCompletionDoesNotFlushAgain(): void public function testScheduledTaskCompletionFollowedByFailureFlushesOnce(): void { - $this->assertScheduledTaskFlushesOnce(static function ( + $this->assertScheduledTaskFlushesAtExecutionEnd(static function ( ConsoleSchedulingFeature $feature, ScheduledEvent $event ): void { @@ -239,29 +257,50 @@ public function testScheduledTaskCompletionFollowedByFailureFlushesOnce(): void } /** - * Assert that a scheduled task terminal sequence flushes exactly once. + * Assert that a scheduled task terminal sequence flushes at execution end. * * @param callable(ConsoleSchedulingFeature, ScheduledEvent): void $terminal */ - private function assertScheduledTaskFlushesOnce(callable $terminal): void + private function assertScheduledTaskFlushesAtExecutionEnd(callable $terminal): void { + $flushed = false; $client = m::mock(ClientInterface::class); $client->shouldReceive('getOptions') - ->once() + ->twice() ->andReturn(new Options); $client->shouldReceive('captureEvent')->never(); $client->shouldReceive('flush') ->once() ->with(null) - ->andReturn(new Result(ResultStatus::success())); + ->andReturnUsing(static function () use (&$flushed): Result { + $flushed = true; + + return new Result(ResultStatus::success()); + }); $feature = new ConsoleSchedulingFeature(m::mock(Container::class)); $event = (new ScheduledEvent(m::mock(EventMutex::class)))->description('Scheduled task'); + $hub = new Hub($client); + $storage = new CoroutineRuntimeContextStorage; + $previousHub = SentrySdk::getCurrentHub(); - $this->withHub(new Hub($client), static function () use ($feature, $event, $terminal): void { + SentrySdk::setRuntimeContextStorage($storage); + SentrySdk::setCurrentHub($hub); + SentrySdk::startContext($hub); + + try { $feature->handleScheduledTaskStarting(new ScheduledTaskStarting($event)); $terminal($feature, $event); - }); + + $this->assertFalse($flushed); + + SentrySdk::endContext(); + + $this->assertTrue($flushed); + } finally { + SentrySdk::endContext(); + SentrySdk::setCurrentHub($previousHub); + } } /** diff --git a/tests/Sentry/Http/FlushEventsMiddlewareTest.php b/tests/Sentry/Http/FlushEventsMiddlewareTest.php index 7af4ca43f..8de942941 100644 --- a/tests/Sentry/Http/FlushEventsMiddlewareTest.php +++ b/tests/Sentry/Http/FlushEventsMiddlewareTest.php @@ -7,9 +7,12 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Sentry\Http\FlushEventsMiddleware; +use Hypervel\Sentry\State\CoroutineRuntimeContextStorage; +use Hypervel\Sentry\State\RuntimeContextBoundary; use Hypervel\Tests\TestCase; use Mockery as m; use Sentry\ClientInterface; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\Transport\Result; @@ -23,7 +26,9 @@ public function testFlushIsDeferredUntilTheRequestCoroutineExits(): void { $handled = new Channel(1); $flushed = new Channel(1); + $storage = new CoroutineRuntimeContextStorage; $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->once()->andReturn(new Options); $client->shouldReceive('flush') ->once() ->with(null) @@ -33,13 +38,23 @@ public function testFlushIsDeferredUntilTheRequestCoroutineExits(): void return new Result(ResultStatus::success()); }); $previousHub = SentrySdk::getCurrentHub(); - SentrySdk::setCurrentHub(new Hub($client)); + $hub = new Hub($client); + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($storage); + SentrySdk::setCurrentHub($hub); + $middleware = new FlushEventsMiddleware( + new RuntimeContextBoundary($hub, $storage), + ); try { - Coroutine::create(static function () use ($handled): void { - $response = (new FlushEventsMiddleware)->handle( + Coroutine::create(function () use ($handled, $middleware, $storage): void { + $response = $middleware->handle( Request::create('/'), - static fn (): Response => new Response('OK'), + function () use ($storage): Response { + $this->assertNotNull($storage->get()); + + return new Response('OK'); + }, ); $handled->push($response->getContent()); diff --git a/tests/Sentry/HttpPoolTransportTest.php b/tests/Sentry/HttpPoolTransportTest.php index aab908be4..ac102620a 100644 --- a/tests/Sentry/HttpPoolTransportTest.php +++ b/tests/Sentry/HttpPoolTransportTest.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; @@ -76,6 +77,27 @@ public function testAcceptedSendReturnsItsEventAndReleasesTransportAfterCompleti $this->assertSame(ResultStatus::success(), $transport->close()->getStatus()); } + public function testDeliveryMarkerIsAvailableToChildStartupHooks(): void + { + $marker = new Channel(1); + Coroutine::afterCreated(static function () use ($marker): void { + $marker->push(CoroutineContext::get(HttpPoolTransport::DELIVERY_CONTEXT_KEY)); + }); + $httpTransport = m::mock(HttpTransport::class); + $httpTransport->shouldReceive('send') + ->once() + ->andReturn(new Result(ResultStatus::success())); + $pool = m::mock(Pool::class); + $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool->shouldReceive('release')->once()->with($httpTransport); + $transport = new HttpPoolTransport($pool); + + $transport->send(Event::createEvent()); + + $this->assertTrue($marker->pop(1.0)); + $this->assertSame(ResultStatus::success(), $transport->close(1)->getStatus()); + } + public function testMultipleSendsThenCloseReleasesAllTransports(): void { $httpTransport1 = m::mock(HttpTransport::class); diff --git a/tests/Sentry/RuntimeContextIsolationTest.php b/tests/Sentry/RuntimeContextIsolationTest.php new file mode 100644 index 000000000..e7bec9358 --- /dev/null +++ b/tests/Sentry/RuntimeContextIsolationTest.php @@ -0,0 +1,84 @@ +start(); + logger()->info('first log'); + traceMetrics()->count('first.metric', 1); + $firstReady->push(true); + $releaseFirst->pop(); + }); + $secondCoroutineId = Coroutine::create(static function () use ($secondReady, $releaseSecond): void { + app(RuntimeContextBoundary::class)->start(); + logger()->info('second log'); + traceMetrics()->count('second.metric', 1); + $secondReady->push(true); + $releaseSecond->pop(); + }); + + $this->assertTrue($firstReady->pop(1.0)); + $this->assertTrue($secondReady->pop(1.0)); + + $releaseFirst->push(true); + Coroutine::join([$firstCoroutineId]); + + $this->assertSame(['first log'], $this->capturedLogBodies()); + $this->assertSame(['first.metric'], $this->capturedMetricNames()); + + $releaseSecond->push(true); + Coroutine::join([$secondCoroutineId]); + + $this->assertSame(['first log', 'second log'], $this->capturedLogBodies()); + $this->assertSame(['first.metric', 'second.metric'], $this->capturedMetricNames()); + } + + /** + * Return captured log bodies in flush order. + * + * @return list + */ + private function capturedLogBodies(): array + { + $events = $this->getCapturedSentryEventsOfType(EventType::logs()); + + return array_map( + static fn (array $event): string => $event[0]->getLogs()[0]->getBody(), + $events, + ); + } + + /** + * Return captured metric names in flush order. + * + * @return list + */ + private function capturedMetricNames(): array + { + $events = $this->getCapturedSentryEventsOfType(EventType::metrics()); + + return array_map( + static fn (array $event): string => $event[0]->getMetrics()[0]->getName(), + $events, + ); + } +} diff --git a/tests/Sentry/SentryTestCase.php b/tests/Sentry/SentryTestCase.php index 6b23f3226..1aac6196c 100644 --- a/tests/Sentry/SentryTestCase.php +++ b/tests/Sentry/SentryTestCase.php @@ -193,25 +193,32 @@ protected function getCapturedSentryEvents(): array return self::$lastSentryEvents; } + /** + * Return captured Sentry events of the given type. + * + * @return list + */ + protected function getCapturedSentryEventsOfType(EventType $eventType): array + { + return array_values(array_filter( + self::$lastSentryEvents, + static fn (array $event): bool => $event[0]->getType() === $eventType, + )); + } + protected function assertSentryEventCount(int $count): void { - $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { - return $event[0]->getType() === EventType::event(); - })); + $this->assertCount($count, $this->getCapturedSentryEventsOfType(EventType::event())); } protected function assertSentryCheckInCount(int $count): void { - $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { - return $event[0]->getType() === EventType::checkIn(); - })); + $this->assertCount($count, $this->getCapturedSentryEventsOfType(EventType::checkIn())); } protected function assertSentryTransactionCount(int $count): void { - $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { - return $event[0]->getType() === EventType::transaction(); - })); + $this->assertCount($count, $this->getCapturedSentryEventsOfType(EventType::transaction())); } protected function startTransaction(): Transaction diff --git a/tests/Sentry/ServiceProviderListenerRegistrationTest.php b/tests/Sentry/ServiceProviderListenerRegistrationTest.php index 3449f2bac..eeb6e841f 100644 --- a/tests/Sentry/ServiceProviderListenerRegistrationTest.php +++ b/tests/Sentry/ServiceProviderListenerRegistrationTest.php @@ -4,10 +4,20 @@ namespace Hypervel\Tests\Sentry; +use Closure; +use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Events\Dispatcher; use Hypervel\Log\Events\MessageLogged; +use Hypervel\Queue\Events\JobProcessing; use Hypervel\Routing\Events\RouteMatched; +use Hypervel\Sentry\SentryServiceProvider; +use Hypervel\Sentry\State\RuntimeContextBoundary; +use Hypervel\WebSocketServer\Events\ConnectionClosing; +use Hypervel\WebSocketServer\Events\ConnectionOpening; +use Hypervel\WebSocketServer\Events\MessageReceived; +use Mockery as m; +use ReflectionFunction; class ServiceProviderListenerRegistrationTest extends SentryTestCase { @@ -78,6 +88,38 @@ public function testMessageLoggedIsRegisteredWhenLogBreadcrumbsAreEnabled(): voi $this->assertSame(1, $this->countMethodListeners(MessageLogged::class, 'messageLogged')); } + public function testRuntimeContextBoundariesPrecedeFeatureListenersAndResolveAtDispatchTime(): void + { + $boundary = m::mock(RuntimeContextBoundary::class); + $boundary->shouldReceive('start')->times(5); + $this->app->instance(RuntimeContextBoundary::class, $boundary); + + foreach ([ + JobProcessing::class, + ScheduledTaskStarting::class, + ConnectionOpening::class, + MessageReceived::class, + ConnectionClosing::class, + ] as $event) { + $listeners = $this->getEventDispatcher()->getRawListeners()[$event] ?? []; + $boundaryListenerIndex = $this->findBoundaryListenerIndex($event); + + $this->assertNotEmpty($listeners); + $this->assertIsInt($boundaryListenerIndex, "Missing Sentry boundary listener for [{$event}]."); + + $listeners[$boundaryListenerIndex](); + } + + $this->assertLessThan( + $this->findMethodListenerIndex(JobProcessing::class, 'handleJobProcessingQueueEvent'), + $this->findBoundaryListenerIndex(JobProcessing::class), + ); + $this->assertLessThan( + $this->findMethodListenerIndex(ScheduledTaskStarting::class, 'handleScheduledTaskStarting'), + $this->findBoundaryListenerIndex(ScheduledTaskStarting::class), + ); + } + private function getEventDispatcher(): Dispatcher { /** @var Dispatcher $dispatcher */ @@ -94,4 +136,33 @@ private function countMethodListeners(string $eventClass, string $method): int && $listener[1] === $method; })); } + + private function findMethodListenerIndex(string $eventClass, string $method): ?int + { + $listeners = $this->getEventDispatcher()->getRawListeners()[$eventClass] ?? []; + + foreach ($listeners as $index => $listener) { + if (is_array($listener) + && isset($listener[1]) + && $listener[1] === $method) { + return $index; + } + } + + return null; + } + + private function findBoundaryListenerIndex(string $eventClass): ?int + { + $listeners = $this->getEventDispatcher()->getRawListeners()[$eventClass] ?? []; + + foreach ($listeners as $index => $listener) { + if ($listener instanceof Closure + && (new ReflectionFunction($listener))->getClosureThis() instanceof SentryServiceProvider) { + return $index; + } + } + + return null; + } } diff --git a/tests/Sentry/ServiceProviderTest.php b/tests/Sentry/ServiceProviderTest.php index c1272f813..179b3bc76 100644 --- a/tests/Sentry/ServiceProviderTest.php +++ b/tests/Sentry/ServiceProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Sentry; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Http\Kernel; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Http\Request; @@ -12,6 +13,7 @@ use Hypervel\Sentry\Features\Feature; use Hypervel\Sentry\Http\FlushEventsMiddleware; use Hypervel\Sentry\Http\SetRequestIpMiddleware; +use Hypervel\Sentry\Hub; use Hypervel\Sentry\SentryConfig; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Sentry\Tracing\Middleware as TracingMiddleware; @@ -20,7 +22,15 @@ use Mockery as m; use Psr\Log\LoggerInterface; use RuntimeException; +use Sentry\ClientInterface; +use Sentry\Event; +use Sentry\Options; +use Sentry\SentrySdk; +use Sentry\State\Hub as SdkHub; use Sentry\State\HubInterface; +use Sentry\State\Scope; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; use Symfony\Component\HttpFoundation\Response; class ServiceProviderTest extends SentryTestCase @@ -60,6 +70,25 @@ public function testDsnWasSetFromConfig(): void $this->assertEquals('publickey', $options->getDsn()->getPublicKey()); } + public function testScopeConfiguredBeforeClientResolutionIsPreserved(): void + { + SentrySdk::init(); + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('configured_before_client', 'preserved'); + }); + $this->app->forgetInstance(HubInterface::class); + CoroutineContext::forget(Hub::CONTEXT_STACK_KEY); + + $event = Event::createEvent(); + $this->getSentryHubFromContainer()->configureScope( + static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }, + ); + + $this->assertSame('preserved', $event->getTags()['configured_before_client'] ?? null); + } + public function testErrorTypesWasSetFromConfig(): void { $this->assertEquals( @@ -74,6 +103,59 @@ public function testArtisanCommandsAreRegistered(): void $this->assertArrayHasKey('sentry:publish', Artisan::all()); } + public function testRootTelemetryFlushesAtApplicationTermination(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('flush') + ->once() + ->withNoArgs() + ->andReturn(new Result(ResultStatus::success())); + $previousHub = SentrySdk::getCurrentHub(); + + try { + SentrySdk::setCurrentHub(new SdkHub($client)); + + $this->app->terminate(); + } finally { + SentrySdk::setCurrentHub($previousHub); + } + } + + public function testApplicationTerminationDoesNotFlushAnActiveExecutionContext(): void + { + $flushed = false; + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') + ->once() + ->andReturn(new Options); + $client->shouldReceive('flush') + ->once() + ->with(null) + ->andReturnUsing(static function () use (&$flushed): Result { + $flushed = true; + + return new Result(ResultStatus::success()); + }); + $previousHub = SentrySdk::getCurrentHub(); + $hub = new SdkHub($client); + + try { + SentrySdk::setCurrentHub($hub); + SentrySdk::startContext($hub); + + $this->app->terminate(); + + $this->assertFalse($flushed); + + SentrySdk::endContext(); + + $this->assertTrue($flushed); + } finally { + SentrySdk::endContext(); + SentrySdk::setCurrentHub($previousHub); + } + } + public function testMiddlewareRegistersThroughTheKernelContract(): void { $kernel = m::mock(Kernel::class); @@ -273,7 +355,7 @@ public function registerFeaturesForTest(): void */ public function bootFeaturesForTest(): void { - $this->bootFeatures(); + $this->bootFeatures($this->isActive()); } } diff --git a/tests/Sentry/ServiceProviderWithoutDsnTest.php b/tests/Sentry/ServiceProviderWithoutDsnTest.php index ddb29c902..f6f34c533 100644 --- a/tests/Sentry/ServiceProviderWithoutDsnTest.php +++ b/tests/Sentry/ServiceProviderWithoutDsnTest.php @@ -4,20 +4,37 @@ namespace Hypervel\Tests\Sentry; +use Closure; +use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Queue\Events\JobProcessing; use Hypervel\Routing\Events\RouteMatched; use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\TestCase; +use Hypervel\WebSocketServer\Events\ConnectionClosing; +use Hypervel\WebSocketServer\Events\ConnectionOpening; +use Hypervel\WebSocketServer\Events\MessageReceived; +use ReflectionFunction; use ReflectionProperty; +use Sentry\SentrySdk; +use Sentry\State\RuntimeContext; +use Sentry\State\RuntimeContextStorageInterface; class ServiceProviderWithoutDsnTest extends TestCase { + protected InMemoryRuntimeContextStorage $runtimeContextStorage; + protected function defineEnvironment(ApplicationContract $app): void { + $this->runtimeContextStorage = new InMemoryRuntimeContextStorage; + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($this->runtimeContextStorage); + SentrySdk::startContext(); + $app->make('config')->set('sentry.dsn', null); } @@ -41,6 +58,16 @@ public function testDsnIsNotSet(): void public function testDidNotRegisterEvents(): void { $this->assertFalse(app('events')->hasListeners(RouteMatched::class)); + + foreach ([ + JobProcessing::class, + ScheduledTaskStarting::class, + ConnectionOpening::class, + MessageReceived::class, + ConnectionClosing::class, + ] as $event) { + $this->assertNull($this->findSentryBoundaryListener($event)); + } } public function testDidNotRegisterAopOrCoroutinePropagation(): void @@ -51,9 +78,68 @@ public function testDidNotRegisterAopOrCoroutinePropagation(): void $this->assertSame([], $callbacks); } + public function testClearsPreviouslyRegisteredRuntimeContextStorage(): void + { + $this->assertNull($this->runtimeContextStorage->get()); + + SentrySdk::startContext(); + + try { + $this->assertNull($this->runtimeContextStorage->get()); + } finally { + SentrySdk::endContext(); + } + } + public function testArtisanCommandsAreRegistered(): void { $this->assertArrayHasKey('sentry:test', Artisan::all()); $this->assertArrayHasKey('sentry:publish', Artisan::all()); } + + private function findSentryBoundaryListener(string $event): ?Closure + { + $listeners = app('events')->getRawListeners()[$event] ?? []; + + foreach ($listeners as $listener) { + if ($listener instanceof Closure + && (new ReflectionFunction($listener))->getClosureThis() instanceof SentryServiceProvider) { + return $listener; + } + } + + return null; + } +} + +class InMemoryRuntimeContextStorage implements RuntimeContextStorageInterface +{ + protected ?RuntimeContext $runtimeContext = null; + + /** + * Return the stored runtime context. + */ + public function get(): ?RuntimeContext + { + return $this->runtimeContext; + } + + /** + * Store the runtime context. + */ + public function set(RuntimeContext $runtimeContext): void + { + $this->runtimeContext = $runtimeContext; + } + + /** + * Remove the stored runtime context. + */ + public function remove(): ?RuntimeContext + { + $runtimeContext = $this->runtimeContext; + $this->runtimeContext = null; + + return $runtimeContext; + } } diff --git a/tests/Sentry/State/CoroutineRuntimeContextStorageTest.php b/tests/Sentry/State/CoroutineRuntimeContextStorageTest.php new file mode 100644 index 000000000..04ca81fe4 --- /dev/null +++ b/tests/Sentry/State/CoroutineRuntimeContextStorageTest.php @@ -0,0 +1,127 @@ +createRuntimeContext(); + + $this->assertNull($storage->get()); + $this->assertNull($storage->remove()); + + $storage->set($runtimeContext); + + $this->assertSame($runtimeContext, $storage->get()); + $this->assertSame($runtimeContext, $storage->remove()); + $this->assertNull($storage->get()); + $this->assertNull($storage->remove()); + } + + public function testParentAndChildReleaseSharedRuntimeContextAfterFinalOwner(): void + { + $storage = new CoroutineRuntimeContextStorage; + $runtimeContext = $this->createRuntimeContext(); + $childReady = new Channel(1); + $releaseChild = new Channel(1); + $childResult = new Channel(1); + + $storage->set($runtimeContext); + + Coroutine::create(static function () use ($storage, $childReady, $releaseChild, $childResult): void { + $context = CoroutineContext::getContainer(); + $parentContext = CoroutineContext::getContainer(Coroutine::parentId()); + + $childReady->push([ + $storage->inheritFrom($context, $parentContext), + $storage->get(), + ]); + $releaseChild->pop(); + $childResult->push($storage->remove()); + }); + + [$inherited, $childRuntimeContext] = $childReady->pop(); + + $this->assertTrue($inherited); + $this->assertSame($runtimeContext, $childRuntimeContext); + $this->assertNull($storage->remove()); + + $releaseChild->push(true); + + $this->assertSame($runtimeContext, $childResult->pop()); + } + + public function testChildReleaseKeepsRuntimeContextForParent(): void + { + $storage = new CoroutineRuntimeContextStorage; + $runtimeContext = $this->createRuntimeContext(); + $childResult = new Channel(1); + + $storage->set($runtimeContext); + + Coroutine::create(static function () use ($storage, $childResult): void { + $context = CoroutineContext::getContainer(); + $parentContext = CoroutineContext::getContainer(Coroutine::parentId()); + + $storage->inheritFrom($context, $parentContext); + $childResult->push($storage->remove()); + }); + + $this->assertNull($childResult->pop()); + $this->assertSame($runtimeContext, $storage->get()); + $this->assertSame($runtimeContext, $storage->remove()); + } + + public function testDoesNotRetainAnAlreadyPopulatedChildContextTwice(): void + { + $storage = new CoroutineRuntimeContextStorage; + $runtimeContext = $this->createRuntimeContext(); + $childResult = new Channel(1); + + $storage->set($runtimeContext); + + Coroutine::create(static function () use ($storage, $childResult): void { + $context = CoroutineContext::getContainer(); + $parentContext = CoroutineContext::getContainer(Coroutine::parentId()); + + $childResult->push([ + $storage->inheritFrom($context, $parentContext), + $storage->inheritFrom($context, $parentContext), + $storage->remove(), + ]); + }); + + [$firstInheritance, $secondInheritance, $releasedRuntimeContext] = $childResult->pop(); + + $this->assertTrue($firstInheritance); + $this->assertFalse($secondInheritance); + $this->assertNull($releasedRuntimeContext); + $this->assertSame($runtimeContext, $storage->remove()); + } + + public function testDoesNotInheritWithoutAParentRuntimeContext(): void + { + $storage = new CoroutineRuntimeContextStorage; + + $this->assertFalse($storage->inheritFrom(new ArrayObject, null)); + $this->assertFalse($storage->inheritFrom(new ArrayObject, new ArrayObject)); + } + + private function createRuntimeContext(): RuntimeContext + { + return new RuntimeContext('test', new Hub); + } +} diff --git a/tests/Sentry/State/RuntimeContextBoundaryTest.php b/tests/Sentry/State/RuntimeContextBoundaryTest.php new file mode 100644 index 000000000..ecf83c9ca --- /dev/null +++ b/tests/Sentry/State/RuntimeContextBoundaryTest.php @@ -0,0 +1,87 @@ +shouldReceive('getOptions')->once()->andReturn(new Options); + $client->shouldReceive('flush') + ->once() + ->with(null) + ->andReturn(new Result(ResultStatus::success())); + $hub = new Hub($client); + $boundary = new RuntimeContextBoundary($hub, $storage); + + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($storage); + + run(function () use ($boundary, $hub, $storage): void { + $boundary->start(); + + $this->assertSame($hub, SentrySdk::getCurrentRuntimeContext()->getHub()); + $this->assertSame(SentrySdk::getCurrentRuntimeContext(), $storage->get()); + }); + + $this->assertNull($storage->get()); + } + + public function testReusesActiveContextWithoutRegisteringAnotherEnd(): void + { + $storage = new CoroutineRuntimeContextStorage; + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->once()->andReturn(new Options); + $client->shouldReceive('flush') + ->once() + ->with(null) + ->andReturn(new Result(ResultStatus::success())); + $boundary = new RuntimeContextBoundary(new Hub($client), $storage); + + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($storage); + + run(function () use ($boundary): void { + $boundary->start(); + $runtimeContext = SentrySdk::getCurrentRuntimeContext(); + + $boundary->start(); + + $this->assertSame($runtimeContext, SentrySdk::getCurrentRuntimeContext()); + }); + } + + public function testDoesNothingOutsideACoroutine(): void + { + $storage = new CoroutineRuntimeContextStorage; + $client = m::mock(ClientInterface::class); + $client->shouldNotReceive('flush'); + $boundary = new RuntimeContextBoundary(new Hub($client), $storage); + + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($storage); + + $boundary->start(); + + $this->assertNull($storage->get()); + } +} diff --git a/tests/Sentry/WebSocketRuntimeContextTest.php b/tests/Sentry/WebSocketRuntimeContextTest.php new file mode 100644 index 000000000..eabe2dcea --- /dev/null +++ b/tests/Sentry/WebSocketRuntimeContextTest.php @@ -0,0 +1,361 @@ +app->make('events')->listen(ConnectionOpening::class, static function (): void { + logger()->info('opening rejected connection'); + }); + + $server = new SentryWebSocketServer( + $this->app, + m::mock(Router::class)->shouldIgnoreMissing(), + m::mock(SwooleWebSocketServer::class), + ); + $request = $this->handshakeRequest(validSecurityKey: false); + $response = $this->handshakeResponse(Response::HTTP_INTERNAL_SERVER_ERROR, 'Handshake failed.'); + + $coroutineId = Coroutine::create(static function () use ($server, $request, $response): void { + $server->onHandshake($request, $response); + }); + Coroutine::join([$coroutineId]); + + $this->assertSame(['opening rejected connection'], $this->capturedLogBodies()); + } + + public function testOpeningContextIncludesDeferredOnOpenTelemetry(): void + { + $this->app->make('events')->listen(ConnectionOpening::class, static function (): void { + logger()->info('connection opening'); + }); + + $handler = new SentryWebSocketRuntimeHandler; + $handler->openCallback = static function (): void { + logger()->info('connection opened'); + }; + $this->app->instance(SentryWebSocketRuntimeHandler::class, $handler); + + $route = m::mock(Route::class); + $route->shouldReceive('getControllerClass')->andReturn(SentryWebSocketRuntimeHandler::class); + $router = m::mock(Router::class); + $router->shouldReceive('dispatchToCallback')->once() + ->andReturnUsing(static function (HttpRequest $request) use ($route): Response { + $request->setRouteResolver(static fn (): Route => $route); + + return new Response('', Response::HTTP_SWITCHING_PROTOCOLS); + }); + $nativeServer = m::mock(SwooleWebSocketServer::class); + $nativeServer->shouldReceive('isEstablished')->once()->with(42)->andReturnTrue(); + $server = new SentryWebSocketServer($this->app, $router, $nativeServer); + $request = $this->handshakeRequest(); + $response = $this->handshakeResponse(Response::HTTP_SWITCHING_PROTOCOLS); + + $coroutineId = Coroutine::create(static function () use ($server, $request, $response): void { + $server->onHandshake($request, $response); + }); + Coroutine::join([$coroutineId]); + + $this->assertSame([ + ['connection opening', 'connection opened'], + ], $this->capturedLogBatches()); + } + + public function testConcurrentMessagesFlushIsolatedTelemetry(): void + { + $firstReady = new Channel(1); + $secondReady = new Channel(1); + $releaseFirst = new Channel(1); + $releaseSecond = new Channel(1); + $handler = new SentryWebSocketRuntimeHandler; + $handler->messageCallback = static function (Frame $frame) use ( + $firstReady, + $secondReady, + $releaseFirst, + $releaseSecond, + ): void { + logger()->info($frame->data . ' log'); + traceMetrics()->count($frame->data . '.metric', 1); + + if ($frame->fd === 1) { + $firstReady->push(true); + $releaseFirst->pop(); + + return; + } + + $secondReady->push(true); + $releaseSecond->pop(); + }; + $this->app->instance(SentryWebSocketRuntimeHandler::class, $handler); + FdCollector::set(1, SentryWebSocketRuntimeHandler::class); + FdCollector::set(2, SentryWebSocketRuntimeHandler::class); + + $server = new Server($this->app); + $nativeServer = m::mock(SwooleWebSocketServer::class); + $firstCoroutineId = Coroutine::create(static function () use ($server, $nativeServer): void { + $server->onMessage($nativeServer, self::frame(1, 'first')); + }); + $secondCoroutineId = Coroutine::create(static function () use ($server, $nativeServer): void { + $server->onMessage($nativeServer, self::frame(2, 'second')); + }); + + $this->assertTrue($firstReady->pop(1.0)); + $this->assertTrue($secondReady->pop(1.0)); + + $releaseFirst->push(true); + Coroutine::join([$firstCoroutineId]); + + $this->assertSame([['first log']], $this->capturedLogBatches()); + $this->assertSame([['first.metric']], $this->capturedMetricBatches()); + + $releaseSecond->push(true); + Coroutine::join([$secondCoroutineId]); + + $this->assertSame([['first log'], ['second log']], $this->capturedLogBatches()); + $this->assertSame([['first.metric'], ['second.metric']], $this->capturedMetricBatches()); + } + + public function testClosingContextFlushesAfterHandlerAndCompletionEvent(): void + { + $handler = new SentryWebSocketRuntimeHandler; + $handler->closeCallback = static function (): void { + logger()->info('connection close handler'); + }; + $this->app->instance(SentryWebSocketRuntimeHandler::class, $handler); + $this->app->make('events')->listen(ConnectionClosed::class, static function (): void { + logger()->info('connection closed'); + }); + FdCollector::set(42, SentryWebSocketRuntimeHandler::class); + + $server = new Server($this->app); + $nativeServer = m::mock(SwooleServer::class); + $coroutineId = Coroutine::create(static function () use ($server, $nativeServer): void { + $server->onClose($nativeServer, 42, 0); + }); + Coroutine::join([$coroutineId]); + + $this->assertSame([ + ['connection close handler', 'connection closed'], + ], $this->capturedLogBatches()); + } + + /** + * Create a native handshake request. + */ + private function handshakeRequest(bool $validSecurityKey = true): SwooleRequest + { + $request = m::mock(SwooleRequest::class); + $request->fd = 42; + $request->server = [ + 'request_method' => 'get', + 'request_uri' => '/socket', + ]; + $request->header = [ + 'host' => 'example.com', + Security::SEC_WEBSOCKET_KEY => $validSecurityKey ? 'dGhlIHNhbXBsZSBub25jZQ==' : 'invalid', + ]; + $request->get = []; + $request->post = []; + $request->cookie = []; + $request->files = []; + $request->shouldReceive('rawContent')->once()->andReturnFalse(); + + return $request; + } + + /** + * Create a native handshake response. + */ + private function handshakeResponse(int $status, string $content = ''): SwooleResponse + { + $response = m::mock(SwooleResponse::class); + $response->shouldReceive('status')->once()->with($status)->andReturnTrue(); + $response->shouldReceive('header')->zeroOrMoreTimes()->andReturnTrue(); + $response->shouldReceive('end')->once()->with($content)->andReturnTrue(); + + return $response; + } + + /** + * Create a WebSocket frame. + */ + private static function frame(int $fd, string $data): Frame + { + $frame = new Frame; + $frame->fd = $fd; + $frame->data = $data; + + return $frame; + } + + /** + * Return captured log bodies grouped by envelope. + * + * @return list> + */ + private function capturedLogBatches(): array + { + return array_map( + static fn (array $event): array => array_map( + static fn (Log $log): string => $log->getBody(), + $event[0]->getLogs(), + ), + $this->getCapturedSentryEventsOfType(EventType::logs()), + ); + } + + /** + * Return captured log bodies in flush order. + * + * @return list + */ + private function capturedLogBodies(): array + { + return array_merge(...$this->capturedLogBatches()); + } + + /** + * Return captured metric names grouped by envelope. + * + * @return list> + */ + private function capturedMetricBatches(): array + { + return array_map( + static fn (array $event): array => array_map( + static fn (Metric $metric): string => $metric->getName(), + $event[0]->getMetrics(), + ), + $this->getCapturedSentryEventsOfType(EventType::metrics()), + ); + } + + protected function setUp(): void + { + parent::setUp(); + + CoordinatorManager::until(Constants::WORKER_START)->resume(); + } + + protected function tearDown(): void + { + FdCollector::flushState(); + WebSocketContext::flushState(); + + parent::tearDown(); + } +} + +class SentryWebSocketServer extends Server +{ + public function __construct( + Container $container, + private readonly Router $router, + private readonly SwooleWebSocketServer $nativeServer, + ) { + parent::__construct($container); + } + + /** + * Get the native WebSocket server. + */ + public function getServer(): SwooleWebSocketServer + { + return $this->nativeServer; + } + + /** + * Get the test router. + */ + protected function getRouter(): Router + { + return $this->router; + } + + /** + * Get the test connection identifier. + */ + protected function getFd(SwooleResponse $response): int + { + return 42; + } + + /** + * Render a failed handshake. + */ + protected function handleException(Throwable $throwable): Response + { + return new Response('Handshake failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } +} + +class SentryWebSocketRuntimeHandler implements OnOpenInterface, OnMessageInterface, OnCloseInterface +{ + public ?Closure $openCallback = null; + + public ?Closure $messageCallback = null; + + public ?Closure $closeCallback = null; + + /** + * Handle a new WebSocket connection. + */ + public function onOpen(SwooleWebSocketServer $server, SwooleRequest $request): void + { + ($this->openCallback)($server, $request); + } + + /** + * Handle an incoming WebSocket message. + */ + public function onMessage(SwooleWebSocketServer $server, Frame $frame): void + { + ($this->messageCallback)($frame); + } + + /** + * Handle a WebSocket connection close. + */ + public function onClose(SwooleServer $server, int $fd, int $reactorId): void + { + ($this->closeCallback)($fd, $reactorId); + } +} diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index 165889b08..adc9f8284 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -53,6 +53,9 @@ use ReflectionClass; use ReflectionProperty; use RuntimeException; +use Sentry\SentrySdk; +use Sentry\State\RuntimeContext; +use Sentry\State\RuntimeContextStorageInterface; use Symfony\Component\VarDumper\VarDumper; class AfterEachTestSubscriberTest extends TestCase @@ -400,6 +403,64 @@ public function flushSaloonStateForTest(): void } } + public function testFrameworkCleanupFlushesSentrySdkState(): void + { + $storage = new class implements RuntimeContextStorageInterface { + public ?RuntimeContext $runtimeContext = null; + + /** + * Return the stored runtime context. + */ + public function get(): ?RuntimeContext + { + return $this->runtimeContext; + } + + /** + * Store a runtime context. + */ + public function set(RuntimeContext $runtimeContext): void + { + $this->runtimeContext = $runtimeContext; + } + + /** + * Remove the stored runtime context. + */ + public function remove(): ?RuntimeContext + { + $runtimeContext = $this->runtimeContext; + $this->runtimeContext = null; + + return $runtimeContext; + } + }; + SentrySdk::init(); + SentrySdk::setRuntimeContextStorage($storage); + SentrySdk::startContext(); + $previousHub = SentrySdk::getCurrentHub(); + + $subscriber = new class extends AfterEachTestSubscriber { + public function flushSentryStateForTest(): void + { + $this->flushSentryState(); + } + }; + + $this->assertNotNull($storage->get()); + + $subscriber->flushSentryStateForTest(); + + $this->assertNull($storage->get()); + $this->assertNotSame($previousHub, SentrySdk::getCurrentHub()); + + SentrySdk::startContext(); + + $this->assertNull($storage->get()); + + SentrySdk::endContext(); + } + public function testTelescopeCleanupReleasesTheDumpHandler(): void { DumpWatcher::flushState(); diff --git a/tests/WebSocketServer/ServerHandshakeTest.php b/tests/WebSocketServer/ServerHandshakeTest.php index 238f5aa35..e1ea5b77a 100644 --- a/tests/WebSocketServer/ServerHandshakeTest.php +++ b/tests/WebSocketServer/ServerHandshakeTest.php @@ -16,10 +16,12 @@ use Hypervel\HttpServer\Events\ResponseSent; use Hypervel\Routing\Route; use Hypervel\Routing\Router; +use Hypervel\Support\SafeCaller; use Hypervel\Tests\TestCase; use Hypervel\Tests\WebSocketServer\Fixtures\WebSocketStub; use Hypervel\WebSocketServer\Collector\FdCollector; use Hypervel\WebSocketServer\Context as WebSocketContext; +use Hypervel\WebSocketServer\Events\ConnectionOpening; use Hypervel\WebSocketServer\Security; use Hypervel\WebSocketServer\Server; use Mockery as m; @@ -29,6 +31,7 @@ use Swoole\Http\Response as SwooleResponse; use Swoole\WebSocket\Server as SwooleWebSocketServer; use Symfony\Component\HttpFoundation\Response; +use Throwable; class ServerHandshakeTest extends TestCase { @@ -38,7 +41,7 @@ public function testDispatchesHttpLifecycleAroundNativeHandshakeEmission(): void $observedEvents = []; $events = new Dispatcher; - foreach ([RequestReceived::class, RequestHandled::class, ResponseSent::class] as $eventClass) { + foreach ([ConnectionOpening::class, RequestReceived::class, RequestHandled::class, ResponseSent::class] as $eventClass) { $events->listen($eventClass, function (object $event) use (&$order, &$observedEvents): void { $order[] = $event::class; $observedEvents[$event::class] = $event; @@ -66,11 +69,15 @@ public function testDispatchesHttpLifecycleAroundNativeHandshakeEmission(): void ))->onHandshake($this->request(), $response); $this->assertSame([ + ConnectionOpening::class, RequestReceived::class, RequestHandled::class, 'send', ResponseSent::class, ], $order); + $this->assertSame(42, $observedEvents[ConnectionOpening::class]->fd); + $this->assertInstanceOf(HttpRequest::class, $observedEvents[ConnectionOpening::class]->request); + $this->assertSame('websocket', $observedEvents[ConnectionOpening::class]->server); $this->assertNull($observedEvents[RequestReceived::class]->response); $this->assertSame(Response::HTTP_SWITCHING_PROTOCOLS, $observedEvents[RequestHandled::class]->response->getStatusCode()); $this->assertSame($observedEvents[RequestHandled::class]->response, $observedEvents[ResponseSent::class]->response); @@ -213,6 +220,66 @@ public function testHandshakeCancellationSkipsFallbackEmissionAndReleasesContext } } + public function testConnectionOpeningFailureIsRenderedAndReleasesContext(): void + { + $exception = new RuntimeException('Opening listener failed.'); + $handledEvent = null; + $events = new Dispatcher; + $events->listen(ConnectionOpening::class, static function () use ($exception): never { + throw $exception; + }); + $events->listen(RequestHandled::class, static function (RequestHandled $event) use (&$handledEvent): void { + $handledEvent = $event; + }); + $container = $this->container($events); + $container->shouldNotReceive('make')->with(Security::class); + $container->shouldReceive('make')->once()->with(SafeCaller::class) + ->andReturn(new SafeCaller($container)); + $router = m::mock(Router::class); + $router->shouldNotReceive('dispatchToCallback'); + $nativeServer = m::mock(SwooleWebSocketServer::class); + $nativeServer->shouldNotReceive('isEstablished'); + + (new RenderingHandshakeLifecycleServer( + $container, + $router, + $nativeServer, + ))->onHandshake($this->request(), $this->response( + Response::HTTP_INTERNAL_SERVER_ERROR, + 'Handled', + )); + + $this->assertInstanceOf(RequestHandled::class, $handledEvent); + $this->assertSame($exception, $handledEvent->exception); + $this->assertNull(FdCollector::get(42)); + $this->assertArrayNotHasKey(42, WebSocketContext::getStorage()); + } + + public function testConnectionOpeningCancellationSkipsFallbackEmissionAndReleasesContext(): void + { + $events = new Dispatcher; + $events->listen(ConnectionOpening::class, static function (): never { + throw new CanceledException; + }); + $container = $this->container($events); + $container->shouldNotReceive('make')->with(Security::class); + $router = m::mock(Router::class); + $router->shouldNotReceive('dispatchToCallback'); + $nativeServer = m::mock(SwooleWebSocketServer::class); + $nativeServer->shouldNotReceive('isEstablished'); + $response = m::mock(SwooleResponse::class); + $response->shouldReceive('status', 'header', 'end')->never(); + + try { + (new HandshakeLifecycleServer($container, $router, $nativeServer)) + ->onHandshake($this->request(), $response); + $this->fail('Expected connection opening cancellation to be rethrown.'); + } catch (CanceledException) { + $this->assertNull(FdCollector::get(42)); + $this->assertArrayNotHasKey(42, WebSocketContext::getStorage()); + } + } + /** * Create the package container mock. */ @@ -338,3 +405,14 @@ protected function getFd(SwooleResponse $response): int return 42; } } + +class RenderingHandshakeLifecycleServer extends HandshakeLifecycleServer +{ + /** + * Render the opening-listener failure. + */ + protected function handleException(Throwable $throwable): Response + { + return new Response('Handled', Response::HTTP_INTERNAL_SERVER_ERROR); + } +} diff --git a/tests/WebSocketServer/ServerTest.php b/tests/WebSocketServer/ServerTest.php index 7b0b439ca..e043c3c77 100644 --- a/tests/WebSocketServer/ServerTest.php +++ b/tests/WebSocketServer/ServerTest.php @@ -18,6 +18,7 @@ use Hypervel\WebSocketServer\Collector\FdCollector; use Hypervel\WebSocketServer\Context as WebSocketContext; use Hypervel\WebSocketServer\Events\ConnectionClosed; +use Hypervel\WebSocketServer\Events\ConnectionClosing; use Hypervel\WebSocketServer\Events\ConnectionOpened; use Hypervel\WebSocketServer\Events\MessageHandled; use Hypervel\WebSocketServer\Events\MessageReceived; @@ -437,13 +438,23 @@ public function testMessageHandledCancellationIsContained(): void $this->assertTrue(WebSocketMessageStub::$messageHandled); } - public function testConnectionClosedEventIsDispatched(): void + public function testConnectionLifecycleEventsAreDispatchedAroundTheCloseHandler(): void { $dispatcher = m::mock(EventDispatcherContract::class); + $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->once()->with(m::on( + fn (ConnectionClosing $event) => ! WebSocketMessageStub::$closeHandled + && $event->fd === 1 + && $event->reactorId === 0 + && $event->server === 'websocket' + ))->ordered(); $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosed::class)->andReturnTrue(); $dispatcher->shouldReceive('dispatch')->once()->with(m::on( - fn (ConnectionClosed $event) => $event->fd === 1 && $event->reactorId === 0 && $event->server === 'websocket' - )); + fn (ConnectionClosed $event) => WebSocketMessageStub::$closeHandled + && $event->fd === 1 + && $event->reactorId === 0 + && $event->server === 'websocket' + ))->ordered(); $container = $this->createContainer(dispatcher: $dispatcher); $container->shouldReceive('make')->with(WebSocketMessageStub::class)->andReturn(new WebSocketMessageStub); @@ -461,6 +472,7 @@ public function testConnectionClosedEventIsDispatched(): void public function testConnectionClosedEventNotDispatchedWithoutListeners(): void { $dispatcher = m::mock(EventDispatcherContract::class); + $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosing::class)->andReturnFalse(); $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosed::class)->andReturnFalse(); $dispatcher->shouldNotReceive('dispatch'); @@ -477,6 +489,57 @@ public function testConnectionClosedEventNotDispatchedWithoutListeners(): void $this->assertTrue(WebSocketMessageStub::$closeHandled); } + public function testConnectionClosingFailureDoesNotSkipCloseCallbacksOrCleanup(): void + { + $exception = new RuntimeException('closing event failed'); + $exceptionHandler = m::mock(ExceptionHandlerContract::class); + $exceptionHandler->shouldReceive('report')->once()->with($exception); + $dispatcher = m::mock(EventDispatcherContract::class); + $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(ConnectionClosing::class)) + ->andThrow($exception); + $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosed::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(ConnectionClosed::class)); + $container = $this->createContainer( + dispatcher: $dispatcher, + exceptionHandler: $exceptionHandler, + ); + $container->shouldReceive('make')->with(WebSocketMessageStub::class)->andReturn(new WebSocketMessageStub); + CoroutineContext::set(WebSocketContext::FD, 1); + WebSocketContext::set('connection.id', 'one'); + FdCollector::set(1, WebSocketMessageStub::class); + + (new Server($container))->onClose(m::mock(SwooleServer::class), 1, 0); + + $this->assertTrue(WebSocketMessageStub::$closeHandled); + $this->assertNull(FdCollector::get(1)); + $this->assertArrayNotHasKey(1, WebSocketContext::getStorage()); + } + + public function testConnectionClosingCancellationSkipsCloseCallbacksAndStillCleansUp(): void + { + $dispatcher = m::mock(EventDispatcherContract::class); + $dispatcher->shouldReceive('hasListeners')->with(ConnectionClosing::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(ConnectionClosing::class)) + ->andThrow(new CanceledException); + $dispatcher->shouldNotReceive('hasListeners')->with(ConnectionClosed::class); + $container = $this->createContainer(dispatcher: $dispatcher); + $container->shouldNotReceive('make')->with(WebSocketMessageStub::class); + CoroutineContext::set(WebSocketContext::FD, 1); + WebSocketContext::set('connection.id', 'one'); + FdCollector::set(1, WebSocketMessageStub::class); + + (new Server($container))->onClose(m::mock(SwooleServer::class), 1, 0); + + $this->assertFalse(WebSocketMessageStub::$closeHandled); + $this->assertNull(FdCollector::get(1)); + $this->assertArrayNotHasKey(1, WebSocketContext::getStorage()); + } + public function testCloseWithoutCollectorStillReleasesConnectionContext(): void { CoroutineContext::set(WebSocketContext::FD, 1); @@ -639,6 +702,10 @@ protected function createContainer( } if ($dispatcher) { + $dispatcher->shouldReceive('hasListeners') + ->with(ConnectionClosing::class) + ->andReturnFalse() + ->byDefault(); $container->shouldReceive('bound')->with('events')->andReturnTrue(); $container->shouldReceive('make')->with('events')->andReturn($dispatcher); } else {