From 53852b824e9003d40405ae21d78e9a6ef1d2a500 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:58:52 +0000 Subject: [PATCH 01/18] Fix coroutine fork initialization ordering Install copied fork context before running after-created callbacks so infrastructure hooks observe the captured snapshot rather than stale parent state. Keep ordinary coroutine creation on an empty initial context and preserve the existing callback and coroutine failure boundaries.\n\nAdd regression coverage for callback visibility and the distinction between create and fork semantics. --- src/coroutine/src/Coroutine.php | 42 ++++++++++++++++++------------- tests/Coroutine/CoroutineTest.php | 19 ++++++++++++++ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 3c6aac45a..932022190 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -93,9 +93,30 @@ public static function pid(?int $coroutineId = null): int */ public static function create(callable $callable): int { - $coroutine = Co::create(static function () use ($callable) { + return self::createWithContext($callable, []); + } + + /** + * Create a coroutine with a copy of the parent coroutine context. + * + * @param array $keys Context keys to copy (empty = all keys) + */ + public static function fork(callable $callable, array $keys = []): int + { + $context = CoroutineContext::captureFrom($keys); + + return self::createWithContext($callable, $context); + } + + /** + * Create a coroutine after installing its initial context. + */ + private static function createWithContext(callable $callable, array $context): int + { + $coroutine = Co::create(static function () use ($callable, $context): void { try { - // Execute afterCreated callbacks. + CoroutineContext::setMany($context); + foreach (static::$afterCreatedCallbacks as $callback) { try { $callback(); @@ -103,6 +124,7 @@ public static function create(callable $callable): int static::printLog($throwable); } } + $callable(); } catch (Throwable $throwable) { static::printLog($throwable); @@ -112,22 +134,6 @@ public static function create(callable $callable): int return $coroutine->getId(); } - /** - * Create a coroutine with a copy of the parent coroutine context. - * - * @param array $keys Context keys to copy (empty = all keys) - */ - public static function fork(callable $callable, array $keys = []): int - { - $context = CoroutineContext::captureFrom($keys); - $callable = static function () use ($callable, $context) { - CoroutineContext::setMany($context); - $callable(); - }; - - return static::create($callable); - } - /** * Wait for the given coroutines to finish. * diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index 8502bd35a..2e4e21679 100644 --- a/tests/Coroutine/CoroutineTest.php +++ b/tests/Coroutine/CoroutineTest.php @@ -133,6 +133,25 @@ public function testAfterCreatedCallbacksExecuteInOrder() $this->assertSame([1, 2, 3], $order); } + public function testForkInstallsCopiedContextBeforeAfterCreatedCallbacks(): void + { + CoroutineContext::set('request-id', 'parent-request'); + $observed = []; + + Coroutine::afterCreated(static function () use (&$observed): void { + $observed['callback'] = CoroutineContext::get('request-id'); + }); + + Coroutine::fork(static function () use (&$observed): void { + $observed['callable'] = CoroutineContext::get('request-id'); + }); + + $this->assertSame([ + 'callback' => 'parent-request', + 'callable' => 'parent-request', + ], $observed); + } + public function testFlushStateClearsAfterCreatedCallbacks() { $count = 0; From 6a6d129a57dbe2d824dc147b6e6904e968216033 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:58:59 +0000 Subject: [PATCH 02/18] Dispatch worker exit lifecycle once Guard the framework worker-exit callback before dispatch so repeated native invocations cannot replay application cleanup or coordinator resumption. Set the guard before listener execution to keep a throwing listener from reopening the lifecycle boundary.\n\nCover repeated callbacks and throwing listeners while preserving the existing server wiring and nonblocking exit behavior. --- src/core/src/Bootstrap/WorkerExitCallback.php | 8 ++++++++ tests/Core/Bootstrap/WorkerExitCallbackTest.php | 17 +++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/core/src/Bootstrap/WorkerExitCallback.php b/src/core/src/Bootstrap/WorkerExitCallback.php index 0bc672911..edad45f3c 100644 --- a/src/core/src/Bootstrap/WorkerExitCallback.php +++ b/src/core/src/Bootstrap/WorkerExitCallback.php @@ -12,6 +12,8 @@ class WorkerExitCallback { + protected bool $dispatched = false; + public function __construct(protected Dispatcher $dispatcher) { } @@ -21,6 +23,12 @@ public function __construct(protected Dispatcher $dispatcher) */ public function onWorkerExit(Server $server, int $workerId): void { + if ($this->dispatched) { + return; + } + + $this->dispatched = true; + try { $this->dispatcher->dispatch(new OnWorkerExit($server, $workerId)); } finally { diff --git a/tests/Core/Bootstrap/WorkerExitCallbackTest.php b/tests/Core/Bootstrap/WorkerExitCallbackTest.php index d596117bc..a0aacd580 100644 --- a/tests/Core/Bootstrap/WorkerExitCallbackTest.php +++ b/tests/Core/Bootstrap/WorkerExitCallbackTest.php @@ -31,11 +31,11 @@ public function testWorkerExitDoesNotRequireAnotherCoroutineSlot(): void ->once() ->with(m::type(OnWorkerExit::class)); $coordinator = CoordinatorManager::until(Constants::WORKER_EXIT); + $callback = new WorkerExitCallback($dispatcher); + $server = m::mock(Server::class); - (new WorkerExitCallback($dispatcher))->onWorkerExit( - m::mock(Server::class), - 3, - ); + $callback->onWorkerExit($server, 3); + $callback->onWorkerExit($server, 3); $this->assertTrue($coordinator->isClosing()); $this->assertSame(1, SwooleCoroutine::stats()['coroutine_num']); @@ -50,17 +50,18 @@ public function testWorkerExitResumesCoordinationWhenAListenerThrows(): void $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('dispatch')->once()->andThrow($failure); $coordinator = CoordinatorManager::until(Constants::WORKER_EXIT); + $callback = new WorkerExitCallback($dispatcher); + $server = m::mock(Server::class); try { - (new WorkerExitCallback($dispatcher))->onWorkerExit( - m::mock(Server::class), - 3, - ); + $callback->onWorkerExit($server, 3); $this->fail('The listener failure should propagate.'); } catch (RuntimeException $exception) { $this->assertSame($failure, $exception); } + $callback->onWorkerExit($server, 3); + $this->assertTrue($coordinator->isClosing()); }); } From 33f3429a1a9daee775cb9994d05451278781c44f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:09 +0000 Subject: [PATCH 03/18] Add capability-based pool invalidation Introduce a small InvalidatesPool contract and expose it through object-pool and pooled-filesystem proxies. This lets operational purge paths invalidate the actual pool owner without coupling managers or decorators to concrete wrapper classes.\n\nClarify fingerprint conflicts around genuinely equivalent construction identities and cover contract forwarding, successful invalidation, and missing-pool behavior. --- src/filesystem/src/ClientPooledFilesystem.php | 3 ++- .../src/Contracts/InvalidatesPool.php | 13 +++++++++++ src/object-pool/src/PoolManager.php | 3 ++- src/object-pool/src/PoolProxy.php | 3 ++- .../Filesystem/ClientPooledFilesystemTest.php | 2 ++ tests/ObjectPool/PoolManagerTest.php | 23 +++++++++++++------ tests/ObjectPool/PoolProxyTest.php | 2 ++ 7 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 src/object-pool/src/Contracts/InvalidatesPool.php diff --git a/src/filesystem/src/ClientPooledFilesystem.php b/src/filesystem/src/ClientPooledFilesystem.php index 0c6ef4464..e748c6ded 100644 --- a/src/filesystem/src/ClientPooledFilesystem.php +++ b/src/filesystem/src/ClientPooledFilesystem.php @@ -8,13 +8,14 @@ use Hypervel\Contracts\Filesystem\Cloud; use Hypervel\Filesystem\Concerns\InteractsWithPooledFilesystem; use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolErrorReporter; use RuntimeException; use Throwable; -class ClientPooledFilesystem implements Cloud +class ClientPooledFilesystem implements Cloud, InvalidatesPool { use InteractsWithPooledFilesystem; diff --git a/src/object-pool/src/Contracts/InvalidatesPool.php b/src/object-pool/src/Contracts/InvalidatesPool.php new file mode 100644 index 000000000..06ed93e51 --- /dev/null +++ b/src/object-pool/src/Contracts/InvalidatesPool.php @@ -0,0 +1,13 @@ +fingerprint}] (requested [{$definition->fingerprint}]). " - . 'Purge the pool or use a distinct explicit pool name.' + . 'Use a distinct explicit pool name, or declare a matching explicit fingerprint ' + . 'only when the differing input does not affect construction.' ); } diff --git a/src/object-pool/src/PoolProxy.php b/src/object-pool/src/PoolProxy.php index 8fd180791..f9595b67b 100644 --- a/src/object-pool/src/PoolProxy.php +++ b/src/object-pool/src/PoolProxy.php @@ -6,10 +6,11 @@ use Closure; use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Contracts\ObjectPool; use Throwable; -class PoolProxy +class PoolProxy implements InvalidatesPool { /** * Create a proxy that resolves its current pool per operation. diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php index 3e4fd2d60..bb6ed47fd 100644 --- a/tests/Filesystem/ClientPooledFilesystemTest.php +++ b/tests/Filesystem/ClientPooledFilesystemTest.php @@ -16,6 +16,7 @@ use Hypervel\Http\Request; use Hypervel\Http\Response; use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\PoolManager; @@ -321,6 +322,7 @@ public function testInvalidatePoolMakesTheNextOperationCreateAFreshClient(): voi $stackCreations = 0; $disk = $this->disk($clientCreations, $stackCreations); + $this->assertInstanceOf(InvalidatesPool::class, $disk); $this->assertTrue($disk->exists('file.txt')); $this->assertTrue($disk->invalidatePool()); $this->assertFalse($disk->invalidatePool()); diff --git a/tests/ObjectPool/PoolManagerTest.php b/tests/ObjectPool/PoolManagerTest.php index 62762fef0..0c09c7e0b 100644 --- a/tests/ObjectPool/PoolManagerTest.php +++ b/tests/ObjectPool/PoolManagerTest.php @@ -196,13 +196,22 @@ public function testFingerprintMismatchThrows(): void { $this->manager->getOrCreate($this->definition(), static fn (): object => new stdClass); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('different construction fingerprint [auto:first] (requested [auto:second])'); - - $this->manager->getOrCreate( - $this->definition(fingerprint: 'auto:second'), - static fn (): object => new stdClass, - ); + try { + $this->manager->getOrCreate( + $this->definition(fingerprint: 'auto:second'), + static fn (): object => new stdClass, + ); + $this->fail('Expected the construction fingerprint mismatch to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString( + 'different construction fingerprint [auto:first] (requested [auto:second])', + $exception->getMessage(), + ); + $this->assertStringContainsString( + 'declare a matching explicit fingerprint only when the differing input does not affect construction', + $exception->getMessage(), + ); + } } public function testOptionsMismatchNamesOnlyDifferingFields(): void diff --git a/tests/ObjectPool/PoolProxyTest.php b/tests/ObjectPool/PoolProxyTest.php index 6d9f302a5..93be2082a 100644 --- a/tests/ObjectPool/PoolProxyTest.php +++ b/tests/ObjectPool/PoolProxyTest.php @@ -8,6 +8,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\ObjectPool\Contracts\Factory; +use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\Contracts\ObjectPool as ObjectPoolContract; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolDefinition; @@ -215,6 +216,7 @@ public function testMetadataAndInvalidationDelegateToTheDefinitionAndFactory(): { $proxy = $this->proxy(static fn (): object => new PoolProxyObject); + $this->assertInstanceOf(InvalidatesPool::class, $proxy); $this->assertSame($this->definition, $proxy->getDefinition()); $this->assertSame($this->definition->identity, $proxy->getPoolName()); $this->assertFalse($proxy->invalidatePool()); From 7c8eb87b1451e7f215026bc2941db0b8270a6bf2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:19 +0000 Subject: [PATCH 04/18] Preserve filesystem identity through decorated disks Carry nullable logical disk names through on-demand, configured, custom, and scoped construction so signed routes, telemetry labels, and whole-driver pool fingerprints remain accurate. Purge pooled disks through the invalidation capability, including uncached configured disks, without reconstructing pool identity in the manager.\n\nRegister signed serving routes from the explicit serve capability rather than a concrete local-driver classification. Document the additive construction seams and pool convergence rules, and cover named, anonymous, scoped, custom, served, and purge paths. --- src/boost/docs/filesystem.md | 28 ++- src/boost/docs/pools.md | 4 + src/filesystem/README.md | 4 + src/filesystem/src/FilesystemManager.php | 63 ++++--- .../src/FilesystemServiceProvider.php | 2 +- src/support/src/Facades/Storage.php | 4 +- tests/Filesystem/FilesystemManagerTest.php | 163 +++++++++++++++++- .../Integration/Filesystem/ServeFileTest.php | 69 ++++++++ 8 files changed, 294 insertions(+), 43 deletions(-) diff --git a/src/boost/docs/filesystem.md b/src/boost/docs/filesystem.md index 4c57b219a..18c8f808a 100644 --- a/src/boost/docs/filesystem.md +++ b/src/boost/docs/filesystem.md @@ -387,6 +387,14 @@ $disk = Storage::build([ $disk->put('image.jpg', $content); ``` +You may also pass a logical disk name as the second argument: + +```php +$disk = Storage::build($configuration, 'tenant-uploads'); +``` + +Hypervel uses this name as part of the pool identity for drivers that pool the complete filesystem instance. Local drivers also use it when generating signed serving URLs, so the name should match a configured served disk when the build must target that disk's registered route. S3 and Google Cloud Storage client pools continue to use the client configuration rather than the logical disk name. + ## Retrieving Files @@ -491,10 +499,10 @@ $url = Storage::temporaryUrl( ); ``` - -#### Enabling Local Temporary URLs + +#### Serving Files From Configured Disks -To generate temporary URLs for files stored using the `local` driver, add the `serve` option to your `local` disk's configuration array within the `config/filesystems.php` configuration file: +To enable Hypervel's signed download and upload routes for a disk, add the `serve` option to the disk's configuration array within the `config/filesystems.php` configuration file. This option is most commonly used to generate temporary URLs for files stored using the `local` driver: ```php 'local' => [ @@ -505,6 +513,8 @@ To generate temporary URLs for files stored using the `local` driver, add the `s ], ``` +Any configured disk may enable these routes. Custom filesystem drivers that enable the `serve` option must provide the filesystem response methods used to serve and receive files. + #### S3 Request Parameters @@ -1073,8 +1083,18 @@ class AppServiceProvider extends ServiceProvider } ``` -The first argument of the `extend` method is the name of the driver and the second is a closure that receives the `$app` and `$config` variables. The closure must return an instance of `Hypervel\Filesystem\FilesystemAdapter`. The `$config` variable contains the values defined in `config/filesystems.php` for the specified disk. +The first argument of the `extend` method is the name of the driver and the second is a closure that receives the `$app` and `$config` variables. The closure may also accept the disk's logical name as a third argument. This value is `null` for an anonymous on-demand disk: + +```php +Storage::extend('dropbox', function (Application $app, array $config, ?string $name) { + // ... +}); +``` + +The closure must return an instance of `Hypervel\Filesystem\FilesystemAdapter`. The `$config` variable contains the values defined in `config/filesystems.php` for the specified disk. You may omit the third argument when your driver does not need the disk name. The optional `poolable` argument determines whether Hypervel should wrap the custom driver in an object pool. This value is `false` by default. You should set it to `true` for custom drivers that hold state that should not be shared across concurrent requests, such as cloud storage SDK clients. +Custom whole-driver pools include the logical disk name in their construction fingerprint. If the name does not affect your custom driver and several named disks may safely share one pool, configure the same `pool.fingerprint` for each disk. A shared `pool.name` may also choose the pool's identity, but it does not replace the shared fingerprint. + Once you have created and registered the extension's service provider, you may use the `dropbox` driver in your `config/filesystems.php` configuration file. diff --git a/src/boost/docs/pools.md b/src/boost/docs/pools.md index 3d5d809a8..a44d283fa 100644 --- a/src/boost/docs/pools.md +++ b/src/boost/docs/pools.md @@ -211,6 +211,10 @@ Hypervel does not provide a generic magic proxy for object pools. A proxy cannot Framework managers for filesystems, mail, and queues build definitions from the actual construction input, expose normalized `pool` configuration, and distinguish cache-only forgetting from pool-invalidating purge operations. Broadcasting does the same only for drivers explicitly marked as poolable. Prefer those manager APIs when using a framework resource instead of creating definitions directly. +Filesystem client pools and whole-driver pools use different construction input. S3 and Google Cloud Storage pools contain only the SDK client, so the logical disk name does not affect their fingerprint. Whole-driver pools contain the complete disk, so built-in and custom drivers include the logical name in their fingerprint. + +If two custom whole-driver disks may safely share a pool despite having different names, configure the same `pool.fingerprint` for both disks. You may also configure the same `pool.name` when you want to choose the shared identity, but the fingerprint must still match. Never declare matching fingerprints when a differing value changes how the pooled object is constructed. + ## Connection Pools diff --git a/src/filesystem/README.md b/src/filesystem/README.md index f28be08a0..a220a130b 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -11,4 +11,8 @@ Hypervel omits Laravel's legacy `Storage::cloud()` / `filesystem.cloud` default- Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks. +Filesystem construction differs from Laravel at two protected points. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. `build()` uses a logical-name-aware construction path rather than `resolve()` because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints; configure a matching explicit fingerprint when differently named disks are deliberately construction-equivalent. + +Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes. + Hypervel also provides `ScopedFilesystemProxy` and `ScopedCloudFilesystemProxy` for prefixes resolved independently on every operation. The underlying disk may be fixed or resolved once per operation when its configuration varies with the current context. These decorators fail closed on empty prefixes and reject unmapped calls so request- or tenant-scoped boundaries cannot be bypassed. diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 16f14caa3..0d682a692 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Filesystem\Factory as FactoryContract; use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; +use Hypervel\ObjectPool\Contracts\InvalidatesPool; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Support\Arr; @@ -134,12 +135,18 @@ public function disk(UnitEnum|string|null $name = null): Filesystem /** * Build an on-demand disk. */ - public function build(array|string $config): Filesystem + public function build(array|string $config, ?string $name = null): Filesystem { - return $this->resolve(self::ON_DEMAND_DISK_NAME, is_array($config) ? $config : [ + $config = is_array($config) ? $config : [ 'driver' => 'local', 'root' => $config, - ]); + ]; + + return $this->resolveWithLogicalName( + $name ?? self::ON_DEMAND_DISK_NAME, + $config, + $name, + ); } /** @@ -156,6 +163,17 @@ protected function get(string $name): Filesystem * @throws InvalidArgumentException */ protected function resolve(string $name, ?array $config = null): Filesystem + { + return $this->resolveWithLogicalName($name, $config, $name); + } + + /** + * Resolve the given disk while preserving its logical construction name. + * + * The configured disk name "ondemand" is valid, so build() enters this + * method directly to carry anonymous construction as a separate value. + */ + private function resolveWithLogicalName(string $name, ?array $config, ?string $logicalName): Filesystem { $config ??= $this->getConfig($name); @@ -172,10 +190,10 @@ protected function resolve(string $name, ?array $config = null): Filesystem ? $this->createDriverPooledDisk( $driver, $config, - null, - fn () => $this->callCustomCreator($constructionConfig), + $logicalName, + fn () => $this->callCustomCreator($constructionConfig, $logicalName), ) - : $this->callCustomCreator($constructionConfig); + : $this->callCustomCreator($constructionConfig, $logicalName); } if ($hasPool && ($driver === 's3' || $driver === 'gcs')) { @@ -203,9 +221,9 @@ protected function resolve(string $name, ?array $config = null): Filesystem /** * Call a custom driver creator. */ - protected function callCustomCreator(array $config): Filesystem + protected function callCustomCreator(array $config, ?string $name = null): Filesystem { - $filesystem = $this->customCreators[$config['driver']]($this->app, $config); + $filesystem = $this->customCreators[$config['driver']]($this->app, $config, $name); if (! $filesystem instanceof Filesystem) { throw new InvalidArgumentException( @@ -278,7 +296,7 @@ protected function diskPoolDefinition(string $driver, array $config, ?string $na $driver === 'gcs' => $this->gcsClientConfig($config), default => [ 'config' => Arr::except($config, ['pool']), - 'name' => isset($this->customCreators[$driver]) ? null : $name, + 'name' => $name, ], }; @@ -557,9 +575,9 @@ protected function clientConfigBlock(array $config, array $supportedKeys): array * * @throws InvalidArgumentException */ - public function createScopedDriver(array $config): Filesystem + public function createScopedDriver(array $config, ?string $name = null): Filesystem { - return $this->build($this->expandScopedConfig($config)); + return $this->build($this->expandScopedConfig($config), $name); } /** @@ -730,25 +748,16 @@ public function purge(?string $name = null): void $disk = $this->disks[$name] ?? null; unset($this->disks[$name]); - if ($disk instanceof ClientPooledFilesystem || $disk instanceof FilesystemPoolProxy) { - $disk->invalidatePool(); - - return; - } - - $config = $this->getConfig($name); + if ($disk === null) { + $config = $this->getConfig($name); - if (($config['driver'] ?? null) === 'scoped') { - // Scoped disks resolve their expanded parent through build(). Use - // that same logical name because whole-driver fingerprints include it. - $config = $this->expandScopedConfig($config); - $name = self::ON_DEMAND_DISK_NAME; + if (! empty($config['driver'])) { + $disk = $this->resolve($name, $config); + } } - $driver = $config['driver'] ?? null; - - if (is_string($driver) && in_array($driver, $this->poolables, true)) { - $this->poolFactory()->remove($this->diskPoolDefinition($driver, $config, $name)->identity); + if ($disk instanceof InvalidatesPool) { + $disk->invalidatePool(); } } diff --git a/src/filesystem/src/FilesystemServiceProvider.php b/src/filesystem/src/FilesystemServiceProvider.php index 901f1222b..433751603 100644 --- a/src/filesystem/src/FilesystemServiceProvider.php +++ b/src/filesystem/src/FilesystemServiceProvider.php @@ -112,7 +112,7 @@ protected function serveFiles(): void */ protected function shouldServeFiles(array $config): bool { - return $config['driver'] === 'local' && ($config['serve'] ?? false); + return ($config['serve'] ?? false) === true; } /** diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 8d5828557..442949ac3 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -13,13 +13,13 @@ /** * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(UnitEnum|string|null $name = null) * @method static \Hypervel\Contracts\Filesystem\Filesystem disk(UnitEnum|string|null $name = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config, string|null $name = null) * @method static \Hypervel\Contracts\Filesystem\Filesystem createLocalDriver(array $config, string $name = 'local') * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem createSftpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Cloud createS3Driver(array $config) * @method static \Hypervel\Contracts\Filesystem\Cloud createGcsDriver(array $config) - * @method static \Hypervel\Contracts\Filesystem\Filesystem createScopedDriver(array $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem createScopedDriver(array $config, string|null $name = null) * @method static \Hypervel\Filesystem\FilesystemManager set(string $name, mixed $disk) * @method static string getDefaultDriver() * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string $disk) diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index ea45bcd16..822adc707 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Filesystem; use Aws\S3\S3Client; +use DateTimeImmutable; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient as GcsClient; use Hypervel\Config\Repository; @@ -172,6 +173,42 @@ public function testCanBuildScopedDisks(): void } } + public function testScopedLocalDiskUsesItsOuterNameForSignedUrls(): void + { + $container = $this->getContainer([ + 'disks' => [ + 'local' => [ + 'driver' => 'local', + 'root' => $this->tempDir . '/signed-scoped', + 'serve' => true, + ], + 'uploads' => [ + 'driver' => 'scoped', + 'disk' => 'local', + 'prefix' => 'tenant', + ], + ], + ]); + $expiration = new DateTimeImmutable('+5 minutes'); + $url = m::mock(); + $url->shouldReceive('temporarySignedRoute') + ->once() + ->with('storage.uploads', $expiration, ['path' => 'file.txt'], false) + ->andReturn('/signed/file.txt'); + $url->shouldReceive('to') + ->once() + ->with('/signed/file.txt') + ->andReturn('https://example.test/signed/file.txt'); + $container->instance('url', $url); + + $filesystem = new FilesystemManager($container); + + $this->assertSame( + 'https://example.test/signed/file.txt', + $filesystem->disk('uploads')->temporaryUrl('file.txt', $expiration), + ); + } + public function testCanBuildScopedDiskFromScopedDisk(): void { try { @@ -558,6 +595,20 @@ public function testPurgeClosesCachedAndNeverCachedClientPools(): void $this->assertFalse($container->make(PoolFactory::class)->has($identity)); } + public function testPurgeRejectsAnUnsupportedConfiguredDriver(): void + { + $manager = new FilesystemManager($this->getContainer([ + 'disks' => [ + 'missing' => ['driver' => 'missing'], + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Driver [missing] is not supported.'); + + $manager->purge('missing'); + } + public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEquivalentPath(): void { foreach (['parent disk', 'another scoped disk', 'on-demand build'] as $source) { @@ -594,7 +645,7 @@ public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEq } } - public function testNeverCachedScopedPurgeUsesTheOnDemandNameForWholeDriverPools(): void + public function testForgottenScopedPurgeUsesTheConfiguredNameForWholeDriverPools(): void { $scopedConfig = [ 'driver' => 'scoped', @@ -612,17 +663,53 @@ public function testNeverCachedScopedPurgeUsesTheOnDemandNameForWholeDriverPools ]); Container::setInstance($container); $manager = (new FilesystemManager($container))->addPoolable('local'); - $disk = $manager->build($scopedConfig); + $disk = $manager->disk('target'); $this->assertFalse($disk->exists('missing.txt')); $identity = $disk->getPoolName(); $this->assertTrue($container->make(PoolFactory::class)->has($identity)); + $manager->forgetDisk('target'); $manager->purge('target'); $this->assertFalse($container->make(PoolFactory::class)->has($identity)); } + public function testPurgingNamedScopedDiskLeavesAnonymousWholeDriverPoolAlone(): void + { + $scopedConfig = [ + 'driver' => 'scoped', + 'disk' => 'local', + 'prefix' => 'tenant', + ]; + $container = $this->getContainer([ + 'disks' => [ + 'local' => [ + 'driver' => 'local', + 'root' => $this->tempDir . '/scoped-pool-isolation', + ], + 'target' => $scopedConfig, + ], + ]); + Container::setInstance($container); + $manager = (new FilesystemManager($container))->addPoolable('local'); + $named = $manager->disk('target'); + $anonymous = $manager->build($scopedConfig); + + $this->assertFalse($named->exists('named-missing.txt')); + $this->assertFalse($anonymous->exists('anonymous-missing.txt')); + $namedIdentity = $named->getPoolName(); + $anonymousIdentity = $anonymous->getPoolName(); + $this->assertNotSame($namedIdentity, $anonymousIdentity); + + $manager->forgetDisk('target'); + $manager->purge('target'); + + $pools = $container->make(PoolFactory::class); + $this->assertFalse($pools->has($namedIdentity)); + $this->assertTrue($pools->has($anonymousIdentity)); + } + public function testNestedScopedDisksComposePrefixesAndPurgeTheSameClientPool(): void { $outerConfig = [ @@ -736,7 +823,51 @@ public function testScopedDiskConfigurationTypesAreValidatedBeforeExpansion(): v } } - public function testCustomPoolableDriversConvergeAndNeverReceivePoolControlMetadata(): void + public function testCustomCreatorsReceiveTheExactLogicalName(): void + { + $config = [ + 'driver' => 'custom', + 'root' => $this->tempDir . '/custom-names', + ]; + $container = $this->getContainer([ + 'disks' => ['ondemand' => $config], + ]); + $received = []; + $manager = new FilesystemManager($container); + $manager->extend('custom', function (Container $app, array $config, ?string $name) use (&$received): FilesystemAdapter { + $received[] = $name; + $adapter = new LocalFilesystemAdapter($config['root']); + + return new FilesystemAdapter(new Flysystem($adapter), $adapter, $config); + }); + + $manager->build($config); + $manager->build($config, 'uploads'); + $manager->disk('ondemand'); + + $this->assertSame([null, 'uploads', 'ondemand'], $received); + } + + public function testProtectedCustomCreatorRemainsCallableWithOneArgument(): void + { + $receivedName = 'unset'; + $manager = new InspectableFilesystemManager($this->getContainer()); + $manager->extend('custom', function (Container $app, array $config, ?string $name) use (&$receivedName): FilesystemAdapter { + $receivedName = $name; + $adapter = new LocalFilesystemAdapter($config['root']); + + return new FilesystemAdapter(new Flysystem($adapter), $adapter, $config); + }); + + $manager->callCustomCreatorForTest([ + 'driver' => 'custom', + 'root' => $this->tempDir . '/custom-one-argument', + ]); + + $this->assertNull($receivedName); + } + + public function testCustomPoolableDriversIncludeLogicalNamesAndExcludePoolMetadata(): void { $root = $this->tempDir . '/custom-pooled'; $config = [ @@ -749,27 +880,36 @@ public function testCustomPoolableDriversConvergeAndNeverReceivePoolControlMetad 'disks' => [ 'first' => $config, 'second' => $config, + 'ondemand' => $config, ], ]); Container::setInstance($container); $received = []; $manager = new FilesystemManager($container); - $manager->extend('custom-pooled', function (Container $app, array $config) use (&$received): FilesystemAdapter { - $received[] = $config; + $manager->extend('custom-pooled', function (Container $app, array $config, ?string $name) use (&$received): FilesystemAdapter { + $received[] = [$config, $name]; $adapter = new LocalFilesystemAdapter($config['root']); return new FilesystemAdapter(new Flysystem($adapter), $adapter, $config); }, poolable: true); $first = $manager->disk('first'); $second = $manager->disk('second'); + $anonymous = $manager->build($config); + $configuredOndemand = $manager->disk('ondemand'); $this->assertInstanceOf(FilesystemPoolProxy::class, $first); - $this->assertSame($first->getPoolName(), $second->getPoolName()); + $this->assertNotSame($first->getPoolName(), $second->getPoolName()); + $this->assertNotSame($anonymous->getPoolName(), $configuredOndemand->getPoolName()); $this->assertFalse($first->exists('missing.txt')); $this->assertFalse($second->exists('missing.txt')); - $this->assertCount(1, $received); - $this->assertArrayNotHasKey('pool', $received[0]); - $this->assertSame('same', $received[0]['marker']); + $this->assertFalse($anonymous->exists('missing.txt')); + $this->assertFalse($configuredOndemand->exists('missing.txt')); + $this->assertSame(['first', 'second', null, 'ondemand'], array_column($received, 1)); + + foreach ($received as [$receivedConfig]) { + $this->assertArrayNotHasKey('pool', $receivedConfig); + $this->assertSame('same', $receivedConfig['marker']); + } } public function testPoolableBuiltInDriversIncludeTheLogicalNameInConstructionIdentity(): void @@ -1090,6 +1230,11 @@ protected function getContainer(array $config = []): Container class InspectableFilesystemManager extends FilesystemManager { + public function callCustomCreatorForTest(array $config): Filesystem + { + return $this->callCustomCreator($config); + } + public function s3ClientConfigForTest(array $config): array { return $this->s3ClientConfig($config); diff --git a/tests/Integration/Filesystem/ServeFileTest.php b/tests/Integration/Filesystem/ServeFileTest.php index 913be8057..3b9eef7bc 100644 --- a/tests/Integration/Filesystem/ServeFileTest.php +++ b/tests/Integration/Filesystem/ServeFileTest.php @@ -4,9 +4,15 @@ namespace Hypervel\Tests\Integration\Filesystem; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\LocalFilesystemAdapter; +use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\Storage; +use Hypervel\Support\Facades\URL; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter as FlysystemLocalAdapter; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; #[WithConfig('filesystems.disks.local.serve', true)] @@ -15,9 +21,16 @@ class ServeFileTest extends TestCase protected function setUp(): void { $this->afterApplicationCreated(function () { + Storage::extend('served-test', function (ApplicationContract $app, array $config): LocalFilesystemAdapter { + $adapter = new FlysystemLocalAdapter($config['root']); + + return new LocalFilesystemAdapter(new Filesystem($adapter), $adapter, $config); + }); + Storage::put('serve-file-test.txt', 'Hello World'); Storage::put('serve-file-test.txt?pad=x', 'Hello Question'); Storage::put('nested/folder/serve-file-test.txt', 'Hello Nested'); + Storage::disk('served-test')->put('serve-file-test.txt', 'Hello Custom Driver'); }); $this->beforeApplicationDestroyed(function () { @@ -26,11 +39,67 @@ protected function setUp(): void 'serve-file-test.txt?pad=x', 'nested/folder/serve-file-test.txt', ]); + Storage::disk('served-test')->delete('serve-file-test.txt'); }); parent::setUp(); } + /** + * Set up the application environment. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + $app->make('config')->set([ + 'filesystems.disks.unserved-absent' => [ + 'driver' => 'local', + 'root' => $app->storagePath('app/unserved-absent'), + 'url' => '/unserved-absent', + ], + 'filesystems.disks.unserved-false' => [ + 'driver' => 'local', + 'root' => $app->storagePath('app/unserved-false'), + 'url' => '/unserved-false', + 'serve' => false, + ], + 'filesystems.disks.served-test' => [ + 'driver' => 'served-test', + 'root' => $app->storagePath('app/served-test'), + 'url' => '/served-test', + 'serve' => true, + ], + ]); + } + + public function testServeConfigurationRegistersOnlyEnabledDiskRoutes(): void + { + $routes = Route::getRoutes(); + + $this->assertNull($routes->getByName('storage.unserved-absent')); + $this->assertNull($routes->getByName('storage.unserved-absent.upload')); + $this->assertNull($routes->getByName('storage.unserved-false')); + $this->assertNull($routes->getByName('storage.unserved-false.upload')); + $this->assertNotNull($routes->getByName('storage.local')); + $this->assertNotNull($routes->getByName('storage.local.upload')); + $this->assertNotNull($routes->getByName('storage.served-test')); + $this->assertNotNull($routes->getByName('storage.served-test.upload')); + } + + public function testItCanServeAFileFromAnOptedInCustomDriver(): void + { + $url = URL::to(URL::temporarySignedRoute( + 'storage.served-test', + now()->addMinutes(1), + ['path' => 'serve-file-test.txt'], + absolute: false, + )); + + $response = $this->get($url); + + $response->assertOk(); + $this->assertSame('Hello Custom Driver', $response->streamedContent()); + } + public function testItCanServeAnExistingFile() { $url = Storage::temporaryUrl('serve-file-test.txt', now()->addMinutes(1)); From 5ba95838ff5279bce886817a9f3c010baf6b1471 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:31 +0000 Subject: [PATCH 05/18] Complete cache operation failure terminals Add exact retrieval-failure events for single and many-key reads, and emit the existing write and forget failure terminals from their owning repository boundaries before rethrowing. Normalize complete many-key results before success fan-out so one handler failure cannot leave a partial terminal sequence.\n\nKeep construction and dispatch behind listener checks, type resolved key lists at the event owner, and make empty aggregate writes true no-ops across repository and Redis tag modes. Add focused lifecycle, exception, empty-batch, and tagged-store coverage plus public event documentation. --- src/boost/docs/cache.md | 38 +++-- src/cache/src/AnyModeTaggedCache.php | 24 ++- src/cache/src/Events/KeyRetrievalFailed.php | 26 +++ .../src/Events/ManyKeysRetrievalFailed.php | 35 ++++ src/cache/src/Events/RetrievingManyKeys.php | 4 + src/cache/src/Events/WritingManyKeys.php | 4 + src/cache/src/Redis/AllTaggedCache.php | 68 ++++++-- src/cache/src/Redis/AnyTaggedCache.php | 40 ++++- src/cache/src/Repository.php | 134 +++++++++++---- src/cache/src/StackTaggedCache.php | 29 +++- tests/Cache/CacheEventsTest.php | 161 ++++++++++++++++++ tests/Cache/CacheRepositoryTest.php | 56 ++++++ tests/Cache/CacheStackStoreTagsTest.php | 99 +++++++++++ tests/Cache/Redis/AllTaggedCacheTest.php | 108 ++++++++++++ tests/Cache/Redis/AnyTaggedCacheTest.php | 119 +++++++++++++ 15 files changed, 869 insertions(+), 76 deletions(-) create mode 100644 src/cache/src/Events/KeyRetrievalFailed.php create mode 100644 src/cache/src/Events/ManyKeysRetrievalFailed.php diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md index 8af1e5d3e..c4388e36a 100644 --- a/src/boost/docs/cache.md +++ b/src/boost/docs/cache.md @@ -1131,24 +1131,26 @@ To execute code on every cache operation, you may listen for various [events](/d | Event Name | |-------------------------------------------------| -| `Hypervel\Cache\Events\CacheFailedOver` | -| `Hypervel\Cache\Events\CacheFlushed` | -| `Hypervel\Cache\Events\CacheFlushing` | -| `Hypervel\Cache\Events\CacheFlushFailed` | -| `Hypervel\Cache\Events\CacheLocksFlushed` | -| `Hypervel\Cache\Events\CacheLocksFlushing` | -| `Hypervel\Cache\Events\CacheLocksFlushFailed` | -| `Hypervel\Cache\Events\CacheHit` | -| `Hypervel\Cache\Events\CacheMissed` | -| `Hypervel\Cache\Events\ForgettingKey` | -| `Hypervel\Cache\Events\KeyForgetFailed` | -| `Hypervel\Cache\Events\KeyForgotten` | -| `Hypervel\Cache\Events\KeyWriteFailed` | -| `Hypervel\Cache\Events\KeyWritten` | -| `Hypervel\Cache\Events\RetrievingKey` | -| `Hypervel\Cache\Events\RetrievingManyKeys` | -| `Hypervel\Cache\Events\WritingKey` | -| `Hypervel\Cache\Events\WritingManyKeys` | +| `Hypervel\Cache\Events\CacheFailedOver` | +| `Hypervel\Cache\Events\CacheFlushed` | +| `Hypervel\Cache\Events\CacheFlushing` | +| `Hypervel\Cache\Events\CacheFlushFailed` | +| `Hypervel\Cache\Events\CacheLocksFlushed` | +| `Hypervel\Cache\Events\CacheLocksFlushing` | +| `Hypervel\Cache\Events\CacheLocksFlushFailed` | +| `Hypervel\Cache\Events\CacheHit` | +| `Hypervel\Cache\Events\CacheMissed` | +| `Hypervel\Cache\Events\ForgettingKey` | +| `Hypervel\Cache\Events\KeyForgetFailed` | +| `Hypervel\Cache\Events\KeyForgotten` | +| `Hypervel\Cache\Events\KeyRetrievalFailed` | +| `Hypervel\Cache\Events\KeyWriteFailed` | +| `Hypervel\Cache\Events\KeyWritten` | +| `Hypervel\Cache\Events\ManyKeysRetrievalFailed` | +| `Hypervel\Cache\Events\RetrievingKey` | +| `Hypervel\Cache\Events\RetrievingManyKeys` | +| `Hypervel\Cache\Events\WritingKey` | +| `Hypervel\Cache\Events\WritingManyKeys` | diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php index 4ddedd578..04673bf97 100644 --- a/src/cache/src/AnyModeTaggedCache.php +++ b/src/cache/src/AnyModeTaggedCache.php @@ -12,7 +12,9 @@ use Hypervel\Cache\Events\ForgettingKey; use Hypervel\Cache\Events\KeyForgetFailed; use Hypervel\Cache\Events\KeyForgotten; +use Hypervel\Cache\Events\KeyRetrievalFailed; use Hypervel\Cache\Events\RetrievingKey; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -136,7 +138,16 @@ protected function getPlainRaw(UnitEnum|string $key): mixed $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key)); - $value = $this->handleIncompleteClass($key, $this->store->get($key)); + try { + $value = $this->handleIncompleteClass($key, $this->store->get($key)); + } catch (Throwable $exception) { + $this->event( + KeyRetrievalFailed::class, + fn (): KeyRetrievalFailed => new KeyRetrievalFailed($this->getName(), $key, $exception) + ); + + throw $exception; + } if (is_null($value)) { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); @@ -167,7 +178,16 @@ protected function forgetPlainKey(UnitEnum|string $key): bool $this->event(ForgettingKey::class, fn (): ForgettingKey => new ForgettingKey($this->getName(), $key)); - $result = $this->store->forget($key); + try { + $result = $this->store->forget($key); + } catch (Throwable $exception) { + $this->event( + KeyForgetFailed::class, + fn (): KeyForgetFailed => new KeyForgetFailed($this->getName(), $key) + ); + + throw $exception; + } if ($result) { $this->event(KeyForgotten::class, fn (): KeyForgotten => new KeyForgotten($this->getName(), $key)); diff --git a/src/cache/src/Events/KeyRetrievalFailed.php b/src/cache/src/Events/KeyRetrievalFailed.php new file mode 100644 index 000000000..848be3533 --- /dev/null +++ b/src/cache/src/Events/KeyRetrievalFailed.php @@ -0,0 +1,26 @@ +exception = $exception; + } +} diff --git a/src/cache/src/Events/ManyKeysRetrievalFailed.php b/src/cache/src/Events/ManyKeysRetrievalFailed.php new file mode 100644 index 000000000..30a3e27ad --- /dev/null +++ b/src/cache/src/Events/ManyKeysRetrievalFailed.php @@ -0,0 +1,35 @@ + + */ + public array $keys; + + /** + * The exception raised while retrieving the keys. + */ + public Throwable $exception; + + /** + * Create a new event instance. + * + * @param list $keys + */ + public function __construct(?string $storeName, array $keys, Throwable $exception, array $tags = []) + { + parent::__construct($storeName, $keys[0] ?? '', $tags); + + $this->keys = $keys; + $this->exception = $exception; + } +} diff --git a/src/cache/src/Events/RetrievingManyKeys.php b/src/cache/src/Events/RetrievingManyKeys.php index 7f811e4ed..168b78b6c 100644 --- a/src/cache/src/Events/RetrievingManyKeys.php +++ b/src/cache/src/Events/RetrievingManyKeys.php @@ -8,11 +8,15 @@ class RetrievingManyKeys extends CacheEvent { /** * The keys that are being retrieved. + * + * @var list */ public array $keys; /** * Create a new event instance. + * + * @param list $keys */ public function __construct(?string $storeName, array $keys, array $tags = []) { diff --git a/src/cache/src/Events/WritingManyKeys.php b/src/cache/src/Events/WritingManyKeys.php index 81eed243d..c887786bd 100644 --- a/src/cache/src/Events/WritingManyKeys.php +++ b/src/cache/src/Events/WritingManyKeys.php @@ -8,6 +8,8 @@ class WritingManyKeys extends CacheEvent { /** * The keys that are being written. + * + * @var list */ public array $keys; @@ -23,6 +25,8 @@ class WritingManyKeys extends CacheEvent /** * Create a new event instance. + * + * @param list $keys */ public function __construct(?string $storeName, array $keys, array $values, ?int $seconds = null, array $tags = []) { diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index b1d3e21d6..01f2cef83 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -17,6 +17,7 @@ use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -101,12 +102,21 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); - $result = $this->store->allTagOps()->put()->execute( - $this->itemKey($key), - $value, - $seconds, - $this->tags->tagIds() - ); + try { + $result = $this->store->allTagOps()->put()->execute( + $this->itemKey($key), + $value, + $seconds, + $this->tags->tagIds() + ); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds) + ); + + throw $exception; + } if ($result) { $this->event( @@ -128,6 +138,10 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT */ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool { + if ($values === []) { + return true; + } + if ($ttl === null) { return $this->putManyForever($values); } @@ -148,12 +162,23 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ ) ); - $result = $this->store->allTagOps()->putMany()->execute( - $values, - $seconds, - $this->tags->tagIds(), - $this->taggedItemKeyPrefix() - ); + try { + $result = $this->store->allTagOps()->putMany()->execute( + $values, + $seconds, + $this->tags->tagIds(), + $this->taggedItemKeyPrefix() + ); + } catch (Throwable $exception) { + foreach ($values as $key => $value) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds) + ); + } + + throw $exception; + } foreach ($values as $key => $value) { if ($result) { @@ -236,11 +261,20 @@ public function forever(UnitEnum|string $key, mixed $value): bool NullSentinel::unwrap($value) )); - $result = $this->store->allTagOps()->forever()->execute( - $this->itemKey($key), - $value, - $this->tags->tagIds() - ); + try { + $result = $this->store->allTagOps()->forever()->execute( + $this->itemKey($key), + $value, + $this->tags->tagIds() + ); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value)) + ); + + throw $exception; + } if ($result) { $this->event( diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index b01cbc0ab..fa2628b93 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -16,6 +16,7 @@ use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -77,7 +78,16 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); - $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); + try { + $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds) + ); + + throw $exception; + } if ($result) { $this->event( @@ -99,6 +109,10 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT */ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool { + if ($values === []) { + return true; + } + if ($ttl === null) { return $this->putManyForever($values); } @@ -127,7 +141,18 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ ) ); - $result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames()); + try { + $result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames()); + } catch (Throwable $exception) { + foreach ($values as $key => $value) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds) + ); + } + + throw $exception; + } foreach ($values as $key => $value) { if ($result) { @@ -179,7 +204,16 @@ public function forever(UnitEnum|string $key, mixed $value): bool NullSentinel::unwrap($value) )); - $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); + try { + $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value)) + ); + + throw $exception; + } if ($result) { $this->event( diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index d4c861a53..d3447d17c 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -21,8 +21,10 @@ use Hypervel\Cache\Events\ForgettingKey; use Hypervel\Cache\Events\KeyForgetFailed; use Hypervel\Cache\Events\KeyForgotten; +use Hypervel\Cache\Events\KeyRetrievalFailed; use Hypervel\Cache\Events\KeyWriteFailed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\Events\ManyKeysRetrievalFailed; use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; @@ -40,6 +42,7 @@ use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; +use Throwable; use UnitEnum; use function Hypervel\Support\defer; @@ -330,7 +333,17 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); - $result = $this->store->put($this->itemKey($key), $value, $seconds); + try { + $result = $this->store->put($this->itemKey($key), $value, $seconds); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds) + ); + + throw $exception; + } + if ($result) { $this->event( KeyWritten::class, @@ -359,6 +372,10 @@ public function set(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt */ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool { + if ($values === []) { + return true; + } + if ($ttl === null) { return $this->putManyForever($values); } @@ -379,7 +396,18 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ ) ); - $result = $this->store->putMany($values, $seconds); + try { + $result = $this->store->putMany($values, $seconds); + } catch (Throwable $exception) { + foreach ($values as $key => $value) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds) + ); + } + + throw $exception; + } foreach ($values as $key => $value) { if ($result) { @@ -470,7 +498,16 @@ public function forever(UnitEnum|string $key, mixed $value): bool $this->event(WritingKey::class, fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value))); - $result = $this->store->forever($this->itemKey($key), $value); + try { + $result = $this->store->forever($this->itemKey($key), $value); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value)) + ); + + throw $exception; + } if ($result) { $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value))); @@ -767,16 +804,27 @@ public function forget(UnitEnum|string $key): bool $this->event(ForgettingKey::class, fn (): ForgettingKey => new ForgettingKey($this->getName(), $key)); - return tap($this->store->forget($this->itemKey($key)), function ($result) use ($key) { - if ($result) { - $this->event(KeyForgotten::class, fn (): KeyForgotten => new KeyForgotten($this->getName(), $key)); - } else { - $this->event( - KeyForgetFailed::class, - fn (): KeyForgetFailed => new KeyForgetFailed($this->getName(), $key) - ); - } - }); + try { + $result = $this->store->forget($this->itemKey($key)); + } catch (Throwable $exception) { + $this->event( + KeyForgetFailed::class, + fn (): KeyForgetFailed => new KeyForgetFailed($this->getName(), $key) + ); + + throw $exception; + } + + if ($result) { + $this->event(KeyForgotten::class, fn (): KeyForgotten => new KeyForgotten($this->getName(), $key)); + } else { + $this->event( + KeyForgetFailed::class, + fn (): KeyForgetFailed => new KeyForgetFailed($this->getName(), $key) + ); + } + + return $result; } public function delete(UnitEnum|string $key): bool @@ -1133,14 +1181,23 @@ public function getRaw(UnitEnum|string $key): mixed $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key)); - // Raw-readable wrappers already passed the value through an inner Repository. - if ($this->store instanceof RawReadable) { - $value = $this->store->getRaw($this->itemKey($key)); - } else { - $value = $this->handleIncompleteClass( - $key, - $this->store->get($this->itemKey($key)) + try { + // Raw-readable wrappers already passed the value through an inner Repository. + if ($this->store instanceof RawReadable) { + $value = $this->store->getRaw($this->itemKey($key)); + } else { + $value = $this->handleIncompleteClass( + $key, + $this->store->get($this->itemKey($key)) + ); + } + } catch (Throwable $exception) { + $this->event( + KeyRetrievalFailed::class, + fn (): KeyRetrievalFailed => new KeyRetrievalFailed($this->getName(), $key, $exception) ); + + throw $exception; } if (is_null($value)) { @@ -1170,7 +1227,9 @@ public function getRaw(UnitEnum|string $key): mixed * invariant that reads through tags are rejected. * * Fires RetrievingManyKeys + per-key CacheHit/CacheMissed events, matching - * the event shape of public many() calls. + * the event shape of public many() calls. Store-read and incomplete-class + * handler failures emit ManyKeysRetrievalFailed; success-listener failures + * occur after retrieval has completed and are not relabeled as read failures. * * Delegates to $this->store->manyRaw() when the underlying store implements * RawReadable (MemoizedStore / FailoverStore). Otherwise calls @@ -1194,21 +1253,34 @@ public function manyRaw(array $keys): array $itemKeys = array_map(fn (string $key): string => $this->itemKey($key), $keys); $rawReadable = $this->store instanceof RawReadable; - $storeValues = $rawReadable - ? $this->store->manyRaw($itemKeys) - : $this->store->many($itemKeys); - $result = []; - foreach ($keys as $i => $key) { - $value = $storeValues[$itemKeys[$i]] ?? null; + try { + $storeValues = $rawReadable + ? $this->store->manyRaw($itemKeys) + : $this->store->many($itemKeys); - // Raw-readable wrappers already passed the value through an inner Repository. - if (! $rawReadable) { - $value = $this->handleIncompleteClass($key, $value); + $result = []; + foreach ($keys as $index => $key) { + $value = $storeValues[$itemKeys[$index]] ?? null; + + // Raw-readable wrappers already passed the value through an inner Repository. + if (! $rawReadable) { + $value = $this->handleIncompleteClass($key, $value); + } + + $result[$key] = $value; } + } catch (Throwable $exception) { + $this->event( + ManyKeysRetrievalFailed::class, + fn (): ManyKeysRetrievalFailed => new ManyKeysRetrievalFailed($this->getName(), $keys, $exception) + ); - $result[$key] = $value; + throw $exception; + } + // Defer terminals until every value is normalized so handler failure cannot follow partial success. + foreach ($result as $key => $value) { if (is_null($value)) { $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key)); } else { diff --git a/src/cache/src/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php index 68ee6a82b..b5c90e397 100644 --- a/src/cache/src/StackTaggedCache.php +++ b/src/cache/src/StackTaggedCache.php @@ -10,6 +10,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\WritingKey; use Hypervel\Contracts\Cache\Store; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -71,10 +72,19 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds) ); - $result = $this->store->putRecordTagged($this->tags->getNames(), $key, [ - 'value' => $value, - 'ttl' => $seconds, - ]); + try { + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, [ + 'value' => $value, + 'ttl' => $seconds, + ]); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds) + ); + + throw $exception; + } if ($result) { $this->event( @@ -118,7 +128,16 @@ public function forever(UnitEnum|string $key, mixed $value): bool NullSentinel::unwrap($value) )); - $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); + try { + $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); + } catch (Throwable $exception) { + $this->event( + KeyWriteFailed::class, + fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value)) + ); + + throw $exception; + } if ($result) { $this->event( diff --git a/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index bcc8329c9..cbee6a33a 100644 --- a/tests/Cache/CacheEventsTest.php +++ b/tests/Cache/CacheEventsTest.php @@ -16,15 +16,20 @@ use Hypervel\Cache\Events\ForgettingKey; use Hypervel\Cache\Events\KeyForgetFailed; use Hypervel\Cache\Events\KeyForgotten; +use Hypervel\Cache\Events\KeyRetrievalFailed; +use Hypervel\Cache\Events\KeyWriteFailed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\Events\ManyKeysRetrievalFailed; use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; +use Hypervel\Cache\Events\WritingManyKeys; use Hypervel\Cache\Repository; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; class CacheEventsTest extends TestCase { @@ -97,6 +102,152 @@ public function testTaggedManyTriggersManyEvents(): void ], $repository->tags('taylor')->many(['baz', 'foo'])); } + public function testGetDispatchesFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache read failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('get')->once()->with('foo')->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->get('foo'); + $this->fail('Expected the cache read exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [RetrievingKey::class, KeyRetrievalFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame('array', $events[1]->storeName); + $this->assertSame('foo', $events[1]->key); + $this->assertSame($exception, $events[1]->exception); + } + + public function testManyDispatchesFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache batch read failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('many')->once()->with(['foo', 'bar'])->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->many(['foo', 'bar']); + $this->fail('Expected the cache batch read exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [RetrievingManyKeys::class, ManyKeysRetrievalFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame('array', $events[1]->storeName); + $this->assertSame(['foo', 'bar'], $events[1]->keys); + $this->assertSame($exception, $events[1]->exception); + } + + public function testPutDispatchesFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache write failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('put')->once()->with('foo', 'bar', 60)->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->put('foo', 'bar', 60); + $this->fail('Expected the cache write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [WritingKey::class, KeyWriteFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame('foo', $events[1]->key); + $this->assertSame('bar', $events[1]->value); + $this->assertSame(60, $events[1]->seconds); + } + + public function testPutManyDispatchesFailureEventsWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache batch write failed.'); + $values = ['foo' => 'bar', 'baz' => 'qux']; + $store = m::mock(Store::class); + $store->shouldReceive('putMany')->once()->with($values, 60)->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->putMany($values, 60); + $this->fail('Expected the cache batch write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [WritingManyKeys::class, KeyWriteFailed::class, KeyWriteFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame(['foo', 'baz'], array_map(static fn (KeyWriteFailed $event): string => $event->key, array_slice($events, 1))); + } + + public function testForeverDispatchesFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache forever write failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('forever')->once()->with('foo', 'bar')->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->forever('foo', 'bar'); + $this->fail('Expected the cache forever write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [WritingKey::class, KeyWriteFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame('foo', $events[1]->key); + $this->assertNull($events[1]->seconds); + } + + public function testForgetDispatchesFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('The cache forget failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('forget')->once()->with('foo')->andThrow($exception); + $events = []; + $repository = new Repository($store, ['store' => 'array']); + $repository->setEventDispatcher($this->getCapturingDispatcher($events)); + + try { + $repository->forget('foo'); + $this->fail('Expected the cache forget exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [ForgettingKey::class, KeyForgetFailed::class], + array_map(get_class(...), $events), + ); + $this->assertSame('foo', $events[1]->key); + } + public function testPullTriggersEvents() { $dispatcher = $this->getDispatcher(); @@ -357,6 +508,16 @@ protected function getDispatcher() return $dispatcher; } + protected function getCapturingDispatcher(array &$events): Dispatcher + { + $dispatcher = $this->getDispatcher(); + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$events): void { + $events[] = $event; + }); + + return $dispatcher; + } + protected function getRepository($dispatcher) { $repository = new Repository(new ArrayStore, ['store' => 'array']); diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index ec2c3b035..a4e8a86de 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -14,6 +14,7 @@ use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\Events\ManyKeysRetrievalFailed; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; @@ -36,6 +37,7 @@ use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use stdClass; class CacheRepositoryTest extends TestCase @@ -277,6 +279,48 @@ public function testManyCallsHandlerForEachIncompleteClass(): void $this->assertSame([['foo', 'stdClass']], $handled); } + public function testManyEmitsOneFailureWithoutPartialSuccessWhenIncompleteClassHandlerThrows(): void + { + $failure = new RuntimeException('Unable to handle cached class.'); + $handled = 0; + Repository::handleUnserializableClassUsing(function () use (&$handled, $failure): void { + if (++$handled === 2) { + throw $failure; + } + }); + + $incomplete = unserialize(serialize(new stdClass), ['allowed_classes' => false]); + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('many')->once()->with(['foo', 'bar'])->andReturn([ + 'foo' => $incomplete, + 'bar' => $incomplete, + ]); + + $captured = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $events->shouldReceive('dispatch')->andReturnUsing(function ($event) use (&$captured): void { + $captured[] = $event; + }); + $repo->setEventDispatcher($events); + + try { + $repo->many(['foo', 'bar']); + $this->fail('Expected the incomplete class handler to throw.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertCount(2, $captured); + $this->assertInstanceOf(RetrievingManyKeys::class, $captured[0]); + $this->assertInstanceOf(ManyKeysRetrievalFailed::class, $captured[1]); + $this->assertSame($failure, $captured[1]->exception); + $this->assertEmpty(array_filter( + $captured, + fn ($event): bool => $event instanceof CacheHit || $event instanceof CacheMissed, + )); + } + public function testRememberNullableStoresAndReturnsNonNullValue() { $repo = $this->getRepository(); @@ -620,6 +664,18 @@ public function testPuttingMultipleItemsInCache() $this->assertTrue(true); } + public function testEmptyPutManyReturnsTrueWithoutStoreOrEvents(): void + { + $repo = $this->getRepository(); + $repo->getStore()->shouldNotReceive('putMany'); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $repo->setEventDispatcher($events); + + $this->assertTrue($repo->putMany([], 60)); + } + public function testSettingMultipleItemsInCacheArray() { $repo = $this->getRepository(); diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index 66baa1e61..c13ca254e 100644 --- a/tests/Cache/CacheStackStoreTagsTest.php +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -9,7 +9,9 @@ use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\ForgettingKey; +use Hypervel\Cache\Events\KeyForgetFailed; use Hypervel\Cache\Events\KeyForgotten; +use Hypervel\Cache\Events\KeyRetrievalFailed; use Hypervel\Cache\Events\KeyWriteFailed; use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\RetrievingKey; @@ -365,6 +367,103 @@ public function testTaggedPutDispatchesRepositoryWriteFailureEvents(): void } } + public function testTaggedRememberDispatchesRepositoryReadFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('read failed'); + $taggable = $this->anyModeTaggableStore(); + $taggable->shouldReceive('get')->once()->with('key')->andThrow($exception); + + $captured = []; + $stack = new StackStore([$taggable]); + $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']); + $cache->setEventDispatcher($this->capturingDispatcher($captured)); + + try { + $cache->remember('key', 60, fn () => 'computed'); + $this->fail('Expected the tagged cache read exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [RetrievingKey::class, KeyRetrievalFailed::class], + array_map(get_class(...), $captured), + ); + $this->assertSame(['tag'], $captured[1]->tags); + $this->assertSame($exception, $captured[1]->exception); + } + + public function testTaggedPutDispatchesRepositoryWriteFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('write failed'); + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('put')->once()->with('key', m::type('array'), 60)->andThrow($exception); + + $captured = []; + $stack = new StackStore([$taggable]); + $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']); + $cache->setEventDispatcher($this->capturingDispatcher($captured)); + + try { + $cache->put('key', 'value', 60); + $this->fail('Expected the tagged cache write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertSame(['tag'], $captured[1]->tags); + } + + public function testTaggedForeverDispatchesRepositoryWriteFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('forever write failed'); + $taggable = $this->anyModeTaggableStore(); + $taggedCache = m::mock(TaggedCache::class); + $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); + $taggedCache->shouldReceive('forever')->once()->with('key', ['value' => 'value'])->andThrow($exception); + + $captured = []; + $stack = new StackStore([$taggable]); + $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']); + $cache->setEventDispatcher($this->capturingDispatcher($captured)); + + try { + $cache->forever('key', 'value'); + $this->fail('Expected the tagged forever exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertSame(['tag'], $captured[1]->tags); + $this->assertNull($captured[1]->seconds); + } + + public function testTaggedExpiredPutDispatchesRepositoryForgetFailureEventWhenTheStoreThrows(): void + { + $exception = new RuntimeException('forget failed'); + $taggable = $this->anyModeTaggableStore(); + $taggable->shouldReceive('forget')->once()->with('key')->andThrow($exception); + + $captured = []; + $stack = new StackStore([$taggable]); + $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']); + $cache->setEventDispatcher($this->capturingDispatcher($captured)); + + try { + $cache->put('key', 'value', 0); + $this->fail('Expected the tagged forget exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([ForgettingKey::class, KeyForgetFailed::class], array_map(get_class(...), $captured)); + $this->assertSame(['tag'], $captured[1]->tags); + } + public function testTaggedPutWithExpiredTtlDispatchesRepositoryDeleteEvents(): void { $taggable = $this->anyModeTaggableStore(); diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 9704f198a..53e8c0b8a 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -17,6 +17,11 @@ use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Redis\AllTaggedCache; use Hypervel\Cache\Redis\AllTagSet; +use Hypervel\Cache\Redis\Operations\AllTag\Forever; +use Hypervel\Cache\Redis\Operations\AllTag\Put; +use Hypervel\Cache\Redis\Operations\AllTag\PutMany; +use Hypervel\Cache\Redis\Operations\AllTagOperations; +use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Redis\PhpRedis; @@ -1230,6 +1235,90 @@ public function testPutDispatchesTheRepositoryFailureEventWithStoreNameAndTags() $this->assertSame(['users'], $captured[1]->tags); } + public function testPutDispatchesTheRepositoryFailureEventWhenTheOperationThrows(): void + { + $exception = new RuntimeException('write failed'); + $put = m::mock(Put::class); + $put->shouldReceive('execute')->once()->andThrow($exception); + + $operations = m::mock(AllTagOperations::class); + $operations->shouldReceive('put')->once()->andReturn($put); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->put('name', 'John', 60); + $this->fail('Expected the tagged cache write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertSame(['users'], $captured[1]->tags); + } + + public function testPutManyDispatchesRepositoryFailureEventsWhenTheOperationThrows(): void + { + $exception = new RuntimeException('batch write failed'); + $values = ['name' => 'John', 'age' => 30]; + $putMany = m::mock(PutMany::class); + $putMany->shouldReceive('execute')->once()->andThrow($exception); + + $operations = m::mock(AllTagOperations::class); + $operations->shouldReceive('putMany')->once()->andReturn($putMany); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->putMany($values, 60); + $this->fail('Expected the tagged cache batch write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [WritingManyKeys::class, KeyWriteFailed::class, KeyWriteFailed::class], + array_map(get_class(...), $captured), + ); + $this->assertSame(['name', 'age'], array_map(static fn (KeyWriteFailed $event): string => $event->key, array_slice($captured, 1))); + } + + public function testEmptyPutManyReturnsTrueWithoutOperationOrEvents(): void + { + $store = m::mock(RedisStore::class); + $store->shouldNotReceive('allTagOps'); + $tags = m::mock(AllTagSet::class); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $tagged = new AllTaggedCache($store, $tags); + $tagged->setEventDispatcher($events); + + $this->assertTrue($tagged->putMany([], 60)); + } + + public function testForeverDispatchesTheRepositoryFailureEventWhenTheOperationThrows(): void + { + $exception = new RuntimeException('forever write failed'); + $forever = m::mock(Forever::class); + $forever->shouldReceive('execute')->once()->andThrow($exception); + + $operations = m::mock(AllTagOperations::class); + $operations->shouldReceive('forever')->once()->andReturn($forever); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->forever('name', 'John'); + $this->fail('Expected the tagged forever exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertNull($captured[1]->seconds); + } + public function testPutManyDispatchesTheRepositoryWriteEventsWithStoreNameAndTags(): void { $connection = $this->mockConnection(); @@ -1320,6 +1409,25 @@ private function capturingDispatcher(array &$captured): Dispatcher return $dispatcher; } + + private function taggedCacheWithOperations(AllTagOperations $operations, array &$captured): AllTaggedCache + { + $store = m::mock(RedisStore::class); + $store->shouldReceive('allTagOps')->once()->andReturn($operations); + + $tags = m::mock(AllTagSet::class); + $tags->shouldReceive('getNames')->andReturn(['users']); + $tags->shouldReceive('getNamespace')->andReturn('users'); + $tags->shouldReceive('tagIds')->andReturn(['users-id']); + + $cache = new AllTaggedCache($store, $tags); + $store->shouldReceive('tags')->once()->with(['users'])->andReturn($cache); + + $tagged = (new Repository($store, ['store' => 'redis']))->tags(['users']); + $tagged->setEventDispatcher($this->capturingDispatcher($captured)); + + return $tagged; + } } enum AllTaggedCacheTestKey: int diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index 6d1ec7d0d..d8977fd64 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -15,10 +15,13 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\WritingKey; +use Hypervel\Cache\Events\WritingManyKeys; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Redis\AnyTaggedCache; use Hypervel\Cache\Redis\AnyTagSet; +use Hypervel\Cache\Redis\Operations\AnyTag\Forever; use Hypervel\Cache\Redis\Operations\AnyTag\Put; +use Hypervel\Cache\Redis\Operations\AnyTag\PutMany; use Hypervel\Cache\Redis\Operations\AnyTagOperations; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; @@ -791,6 +794,99 @@ public function testPutDispatchesTheRepositoryFailureEvent(): void } } + public function testPutDispatchesTheRepositoryFailureEventWhenTheOperationThrows(): void + { + $exception = new RuntimeException('write failed'); + $put = m::mock(Put::class); + $put->shouldReceive('execute') + ->once() + ->with('mykey', 'myvalue', 60, ['users']) + ->andThrow($exception); + + $operations = m::mock(AnyTagOperations::class); + $operations->shouldReceive('put')->once()->andReturn($put); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->put('mykey', 'myvalue', 60); + $this->fail('Expected the tagged cache write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertSame(['users'], $captured[1]->tags); + } + + public function testPutManyDispatchesRepositoryFailureEventsWhenTheOperationThrows(): void + { + $exception = new RuntimeException('batch write failed'); + $values = ['first' => 'one', 'second' => 'two']; + $putMany = m::mock(PutMany::class); + $putMany->shouldReceive('execute') + ->once() + ->with($values, 60, ['users']) + ->andThrow($exception); + + $operations = m::mock(AnyTagOperations::class); + $operations->shouldReceive('putMany')->once()->andReturn($putMany); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->putMany($values, 60); + $this->fail('Expected the tagged cache batch write exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame( + [WritingManyKeys::class, KeyWriteFailed::class, KeyWriteFailed::class], + array_map(get_class(...), $captured), + ); + $this->assertSame(['first', 'second'], array_map(static fn (KeyWriteFailed $event): string => $event->key, array_slice($captured, 1))); + } + + public function testEmptyPutManyReturnsTrueWithoutOperationOrEvents(): void + { + $store = m::mock(RedisStore::class); + $store->shouldNotReceive('anyTagOps'); + $tags = m::mock(AnyTagSet::class); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $tagged = new AnyTaggedCache($store, $tags); + $tagged->setEventDispatcher($events); + + $this->assertTrue($tagged->putMany([], 60)); + } + + public function testForeverDispatchesTheRepositoryFailureEventWhenTheOperationThrows(): void + { + $exception = new RuntimeException('forever write failed'); + $forever = m::mock(Forever::class); + $forever->shouldReceive('execute') + ->once() + ->with('mykey', 'myvalue', ['users']) + ->andThrow($exception); + + $operations = m::mock(AnyTagOperations::class); + $operations->shouldReceive('forever')->once()->andReturn($forever); + $captured = []; + $tagged = $this->taggedCacheWithOperations($operations, $captured); + + try { + $tagged->forever('mykey', 'myvalue'); + $this->fail('Expected the tagged forever exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured)); + $this->assertNull($captured[1]->seconds); + } + public function testRememberNullableStoresAndReturnsNonNullValue(): void { $connection = $this->mockConnection(); @@ -1233,6 +1329,29 @@ public function testFlexibleNullableThrowsBadMethodCallException(): void $cache->flexibleNullable('mykey', [60, 120], fn () => 'v'); } + + private function taggedCacheWithOperations(AnyTagOperations $operations, array &$captured): AnyTaggedCache + { + $store = m::mock(RedisStore::class); + $store->shouldReceive('anyTagOps')->once()->andReturn($operations); + + $tags = m::mock(AnyTagSet::class); + $tags->shouldReceive('getNames')->andReturn(['users']); + + $cache = new AnyTaggedCache($store, $tags); + $store->shouldReceive('tags')->once()->with(['users'])->andReturn($cache); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue(); + $events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$captured): void { + $captured[] = $event; + }); + + $tagged = (new Repository($store, ['store' => 'redis']))->tags(['users']); + $tagged->setEventDispatcher($events); + + return $tagged; + } } enum AnyTaggedCacheTestKey: int From 1ef32cf15cf9e5d28d3b96e83b5553c10230bc46 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:39 +0000 Subject: [PATCH 06/18] Expose skipped and delivered notification boundaries Dispatch NotificationSkipped when shouldSend or NotificationSending rejects delivery, and dispatch NotificationDelivered immediately after the channel returns. Preserve the existing afterSending and NotificationSent order while keeping pre-delivery failure deduplication and allowing post-delivery callback failures to propagate without false relabeling.\n\nGuard both new events with listener checks and cover veto, success, transport failure, post-delivery failure, ordering, and coroutine-local failure ownership. --- src/boost/docs/notifications.md | 45 +++++ .../src/Events/NotificationDelivered.php | 26 +++ .../src/Events/NotificationSkipped.php | 25 +++ src/notifications/src/NotificationSender.php | 29 ++- .../NotificationChannelManagerTest.php | 10 +- .../Notifications/NotificationSenderTest.php | 178 ++++++++++++++++++ 6 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/notifications/src/Events/NotificationDelivered.php create mode 100644 src/notifications/src/Events/NotificationSkipped.php diff --git a/src/boost/docs/notifications.md b/src/boost/docs/notifications.md index d90715f91..cfa19617d 100644 --- a/src/boost/docs/notifications.md +++ b/src/boost/docs/notifications.md @@ -1803,6 +1803,51 @@ public function handle(NotificationSending $event): void } ``` + +#### Notification Skipped Event + +When a notification's `shouldSend` method returns `false`, or a `NotificationSending` listener stops delivery, the `Hypervel\Notifications\Events\NotificationSkipped` event is dispatched. The event provides the `notifiable`, `notification`, and `channel` properties: + +```php +use Hypervel\Notifications\Events\NotificationSkipped; + +class LogSkippedNotification +{ + /** + * Handle the event. + */ + public function handle(NotificationSkipped $event): void + { + // $event->channel + // $event->notifiable + // $event->notification + } +} +``` + + +#### Notification Delivered Event + +After the notification channel returns, the `Hypervel\Notifications\Events\NotificationDelivered` event is dispatched before the notification's `afterSending` method and the `NotificationSent` event. This event provides the channel response in addition to the `notifiable`, `notification`, and `channel` properties: + +```php +use Hypervel\Notifications\Events\NotificationDelivered; + +class RecordNotificationDelivery +{ + /** + * Handle the event. + */ + public function handle(NotificationDelivered $event): void + { + // $event->channel + // $event->notifiable + // $event->notification + // $event->response + } +} +``` + #### Notification Sent Event diff --git a/src/notifications/src/Events/NotificationDelivered.php b/src/notifications/src/Events/NotificationDelivered.php new file mode 100644 index 000000000..1423533a8 --- /dev/null +++ b/src/notifications/src/Events/NotificationDelivered.php @@ -0,0 +1,26 @@ +id = $id; } - if (! $this->shouldSendNotification($notifiable, $notification, $channel)) { - return; - } - $previousFailureState = CoroutineContext::get(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY); CoroutineContext::set(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY, false); + $response = null; try { - $response = $this->manager->driver($channel)->send($notifiable, $notification); + $shouldSend = $this->shouldSendNotification($notifiable, $notification, $channel); + + if ($shouldSend) { + $response = $this->manager->driver($channel)->send($notifiable, $notification); + } } catch (Throwable $exception) { if (CoroutineContext::get(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY) !== true) { if ($exception instanceof HttpTransportException) { @@ -150,6 +153,22 @@ protected function sendToNotifiable(mixed $notifiable, string $id, mixed $notifi } } + if (! $shouldSend) { + if ($this->events->hasListeners(NotificationSkipped::class)) { + $this->events->dispatch( + new NotificationSkipped($notifiable, $notification, $channel) + ); + } + + return; + } + + if ($this->events->hasListeners(NotificationDelivered::class)) { + $this->events->dispatch( + new NotificationDelivered($notifiable, $notification, $channel, $response) + ); + } + if (method_exists($notification, 'afterSending')) { $notification->afterSending($notifiable, $channel, $response); } diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 79c569ab4..47506a840 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -18,9 +18,11 @@ use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Channels\MailChannel; use Hypervel\Notifications\Channels\SlackNotificationRouterChannel; +use Hypervel\Notifications\Events\NotificationDelivered; use Hypervel\Notifications\Events\NotificationFailed; use Hypervel\Notifications\Events\NotificationSending; use Hypervel\Notifications\Events\NotificationSent; +use Hypervel\Notifications\Events\NotificationSkipped; use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationServiceProvider; @@ -99,6 +101,7 @@ public function testNotificationCanBeDispatchedToDriver(): void $manager->shouldReceive('driver')->andReturn($driver = m::mock()); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $driver->shouldReceive('send')->once(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->with(m::type(NotificationSent::class)); $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); @@ -114,6 +117,8 @@ public function testNotificationNotSentOnHalt(): void $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); $driver->shouldReceive('send')->once(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSkipped::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->with(m::type(NotificationSent::class)); $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestNotificationWithTwoChannels); @@ -127,7 +132,7 @@ public function testNotificationNotSentWhenCancelled(): void $manager = m::mock(ChannelManager::class . '[driver]', [$container]); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $manager->shouldNotReceive('driver'); - $events->shouldNotReceive('dispatch'); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSkipped::class)); $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestCancelledNotification); } @@ -141,6 +146,7 @@ public function testNotificationSentWhenNotCancelled(): void $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); $driver->shouldReceive('send')->once(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSent::class)); $manager->send([new NotificationChannelManagerTestNotifiable], new NotificationChannelManagerTestNotCancelledNotification); @@ -158,6 +164,7 @@ public function testNotificationNotSentWhenFailed(): void $driver->shouldReceive('send')->andThrow(new Exception); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $events->shouldReceive('dispatch')->once()->with(m::type(NotificationFailed::class)); + $events->shouldReceive('dispatch')->never()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->never()->with(m::type(NotificationSent::class)); $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); @@ -403,6 +410,7 @@ public function testAfterSendingMethodAfterSendingNotification(): void $manager->shouldReceive('driver')->andReturn($driver = m::mock()); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); $driver->shouldReceive('send')->once()->andReturn($response = m::mock()); + $events->shouldReceive('dispatch')->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->with(m::type(NotificationSent::class)); $manager->send($notifiable = new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerWithAfterSendingMethodNotification); diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 3865a3d3d..6fc4bc349 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -4,15 +4,18 @@ namespace Hypervel\Tests\Notifications; +use Closure; use Hypervel\Bus\Queueable; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\ChannelManager; +use Hypervel\Notifications\Events\NotificationDelivered; use Hypervel\Notifications\Events\NotificationFailed; use Hypervel\Notifications\Events\NotificationSending; use Hypervel\Notifications\Events\NotificationSent; +use Hypervel\Notifications\Events\NotificationSkipped; use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; @@ -20,6 +23,8 @@ use Hypervel\Queue\Attributes\Queue; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; +use stdClass; use Symfony\Component\Mailer\Exception\HttpTransportException; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -39,6 +44,7 @@ public function testItCanSendNotificationsWithAStringVia(): void $bus->shouldNotReceive('dispatch'); $events = $this->mockEventDispatcher(); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSent::class)); $sender = new NotificationSender($manager, $bus, $events); @@ -46,6 +52,164 @@ public function testItCanSendNotificationsWithAStringVia(): void $sender->send($notifiable, new DummyNotificationWithStringVia); } + public function testNotificationLifecycleUsesTheDeliveryBoundaryBeforePostDeliveryCallbacks(): void + { + $order = []; + $response = new stdClass; + $notifiable = new AnonymousNotifiable; + $notification = new DummyNotificationWithAfterSendingCallback( + static function () use (&$order): void { + $order[] = 'after-sending'; + } + ); + $manager = m::mock(ChannelManager::class); + $manager->shouldReceive('driver')->once()->with('mail')->andReturn($driver = m::mock()); + $driver->shouldReceive('send')->once()->andReturnUsing(function () use (&$order, $response): object { + $order[] = 'channel'; + + return $response; + }); + $events = $this->mockEventDispatcher(); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andReturnUsing( + function () use (&$order): bool { + $order[] = 'sending'; + + return true; + } + ); + $events->shouldReceive('dispatch')->twice()->andReturnUsing( + function (object $event) use (&$order, $notifiable, $response): void { + $this->assertSame($notifiable, $event->notifiable); + $this->assertSame('mail', $event->channel); + + if ($event instanceof NotificationDelivered) { + $this->assertSame($response, $event->response); + $order[] = 'delivered'; + + return; + } + + $this->assertInstanceOf(NotificationSent::class, $event); + $order[] = 'sent'; + } + ); + + (new NotificationSender( + $manager, + m::mock(BusDispatcherContract::class), + $events, + ))->sendNow($notifiable, $notification, ['mail']); + + $this->assertSame(['sending', 'channel', 'delivered', 'after-sending', 'sent'], $order); + } + + public function testShouldSendCancellationDispatchesSkippedWithoutResolvingTheChannel(): void + { + $notifiable = new AnonymousNotifiable; + $notification = new class extends Notification { + public function shouldSend(mixed $notifiable, string $channel): bool + { + return false; + } + }; + $manager = m::mock(ChannelManager::class); + $manager->shouldNotReceive('driver'); + $events = $this->mockEventDispatcher(); + $events->shouldNotReceive('until'); + $events->shouldReceive('dispatch')->once()->with(m::on( + fn (object $event): bool => $event instanceof NotificationSkipped + && $event->notifiable === $notifiable + && $event->channel === 'mail' + )); + + (new NotificationSender( + $manager, + m::mock(BusDispatcherContract::class), + $events, + ))->sendNow($notifiable, $notification, ['mail']); + } + + public function testSendingVetoDispatchesSkippedWithoutResolvingTheChannel(): void + { + $manager = m::mock(ChannelManager::class); + $manager->shouldNotReceive('driver'); + $events = $this->mockEventDispatcher(); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andReturnFalse(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSkipped::class)); + + (new NotificationSender( + $manager, + m::mock(BusDispatcherContract::class), + $events, + ))->sendNow(new AnonymousNotifiable, new Notification, ['mail']); + } + + public function testThrowingSendingListenerDispatchesFailedAndRethrows(): void + { + $exception = new RuntimeException('sending listener failed'); + $manager = m::mock(ChannelManager::class); + $manager->shouldNotReceive('driver'); + $events = $this->mockEventDispatcher(); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andThrow($exception); + $events->shouldReceive('dispatch')->once()->with(m::on( + fn (object $event): bool => $event instanceof NotificationFailed + && $event->data['exception'] === $exception + )); + $sender = new NotificationSender($manager, m::mock(BusDispatcherContract::class), $events); + + try { + $sender->sendNow(new AnonymousNotifiable, new Notification, ['mail']); + $this->fail('Expected the sending-listener exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + } + + public function testThrowingDeliveredListenerIsNotRelabeledAsDeliveryFailure(): void + { + $exception = new RuntimeException('delivered listener failed'); + $manager = m::mock(ChannelManager::class); + $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); + $driver->shouldReceive('send')->once()->andReturn('response'); + $events = $this->mockEventDispatcher(); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class))->andThrow($exception); + $events->shouldReceive('dispatch')->never()->with(m::type(NotificationFailed::class)); + $events->shouldReceive('dispatch')->never()->with(m::type(NotificationSent::class)); + $sender = new NotificationSender($manager, m::mock(BusDispatcherContract::class), $events); + + try { + $sender->sendNow(new AnonymousNotifiable, new Notification, ['mail']); + $this->fail('Expected the delivered-listener exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + } + + public function testThrowingAfterSendingCallbackIsNotRelabeledAsDeliveryFailure(): void + { + $exception = new RuntimeException('after-sending failed'); + $notification = new DummyNotificationWithAfterSendingCallback(static function () use ($exception): never { + throw $exception; + }); + $manager = m::mock(ChannelManager::class); + $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); + $driver->shouldReceive('send')->once()->andReturn('response'); + $events = $this->mockEventDispatcher(); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); + $events->shouldReceive('dispatch')->never()->with(m::type(NotificationFailed::class)); + $events->shouldReceive('dispatch')->never()->with(m::type(NotificationSent::class)); + $sender = new NotificationSender($manager, m::mock(BusDispatcherContract::class), $events); + + try { + $sender->sendNow(new AnonymousNotifiable, $notification, ['mail']); + $this->fail('Expected the after-sending exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + } + public function testItCanSendQueuedNotificationsWithAStringVia(): void { $notifiable = m::mock(Notifiable::class); @@ -356,6 +520,7 @@ public function testItPreservesNotificationStateMutatedInViaMethod(): void $events = $this->mockEventDispatcher(); $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSent::class)); $sender = new NotificationSender($manager, $bus, $events); @@ -473,6 +638,7 @@ public function testNotificationEventsAreSkippedWhenNoListenersAreRegistered(): $bus->shouldNotReceive('dispatch'); $events = $this->mockEventDispatcher(); $events->shouldReceive('hasListeners')->with(NotificationSending::class)->andReturn(false); + $events->shouldReceive('hasListeners')->with(NotificationDelivered::class)->andReturn(false); $events->shouldReceive('hasListeners')->with(NotificationSent::class)->andReturn(false); $events->shouldNotReceive('until'); $events->shouldNotReceive('dispatch'); @@ -513,6 +679,18 @@ private function mockEventDispatcher(): Dispatcher } } +class DummyNotificationWithAfterSendingCallback extends Notification +{ + public function __construct(private Closure $callback) + { + } + + public function afterSending(mixed $notifiable, string $channel, mixed $response): void + { + ($this->callback)($notifiable, $channel, $response); + } +} + class DummyQueuedNotificationWithStringVia extends Notification implements ShouldQueue { use Queueable; From 7c351de15218de509be0f55295d6d13e6a4754fd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:59:49 +0000 Subject: [PATCH 07/18] Complete queue enqueue lifecycle ownership Emit JobQueueingFailed from the exact enqueue attempt boundary and expose whether worker termination must return immediately. Preserve Database bulk inserts while partitioning immediate and after-commit groups, reacquiring deferred queues, computing delay at the actual attempt, and emitting one exact terminal per attempted job.\n\nMake SQS batch lifecycle events follow attempted chunks and correlate AWS responses by entry id, including explicit rejects and ambiguous request failures. Add regression coverage for single, bulk, rollback, deferred, partial, overflow, and forced-stop behavior. --- src/boost/docs/queues.md | 4 + src/queue/src/DatabaseQueue.php | 120 +++++++++++- src/queue/src/Events/JobQueueingFailed.php | 34 ++++ src/queue/src/Events/WorkerStopping.php | 2 + src/queue/src/Queue.php | 55 +++++- src/queue/src/SqsQueue.php | 78 +++++--- src/queue/src/Worker.php | 2 + tests/Queue/QueueDatabaseQueueUnitTest.php | 205 +++++++++++++++++++++ tests/Queue/QueueRedisQueueTest.php | 54 ++++++ tests/Queue/QueueSqsQueueTest.php | 75 +++++++- tests/Queue/QueueWorkerTest.php | 9 +- 11 files changed, 586 insertions(+), 52 deletions(-) create mode 100644 src/queue/src/Events/JobQueueingFailed.php diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index 3d9f0d093..348e210f4 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -3715,6 +3715,8 @@ class AppServiceProvider extends ServiceProvider } ``` +Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. + Using the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job: ```php @@ -3740,3 +3742,5 @@ Event::listen(function (WorkerIdle $event) { // $event->workerOptions }); ``` + +Queue workers also dispatch a `WorkerStopping` event before they stop. Its `terminatesImmediately` property is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 3f56937f7..e3ba2b320 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Query\Builder; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Jobs\DatabaseJob; @@ -263,26 +264,125 @@ static function ( /** * Push an array of jobs onto the queue. + * + * Immediate and after-commit jobs use one bulk insert per attempted group. + * Deferred groups reacquire the queue through the after-commit dispatcher, + * and the return value remains null when every job is deferred. */ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixed { - $queue = $this->getQueue($queue); + $jobs = array_values($jobs); + + if ($jobs === []) { + return null; + } + + $transactions = null; + + if ($this->container->has('db.transactions')) { + /** @var DatabaseTransactionsManager $transactions */ + $transactions = $this->container->make('db.transactions'); + } + + [$afterCommit, $immediate] = $this->partitionJobsByAfterCommit($jobs, $transactions); + + $result = null; + + if ($immediate !== []) { + $result = $this->enqueueBatch($this->prepareBatchJobs($immediate, $data, $queue), $queue); + } + + if ($afterCommit === []) { + return $result; + } + + $preparedJobs = $this->prepareBatchJobs($afterCommit, $data, $queue); + + // A non-empty deferred group means partitionJobsByAfterCommit() resolved a transactions manager. + foreach ($afterCommit as $job) { + /** @var DatabaseTransactionsManager $transactions */ + $this->addUniqueJobRollbackCallback($transactions, $job); + $this->addDebouncedJobRollbackCallback($transactions, $job); + } + + if ($this->afterCommitDispatcher !== null) { + $dispatcher = $this->afterCommitDispatcher; + + $transactions->addCallback( + static fn () => $dispatcher( + static function (Queue $owner) use ($preparedJobs, $queue): mixed { + /** @var DatabaseQueue $owner */ + return $owner->enqueueBatch($preparedJobs, $queue); + } + ) + ); + + return $result; + } - $now = $this->availableAt(); + $transactions->addCallback( + fn () => $this->enqueueBatch($preparedJobs, $queue) + ); + + return $result; + } - return $this->getDatabase()->table($this->table)->insert(Collection::make((array) $jobs)->map( - function ($job) use ($queue, $data, $now) { + /** + * Prepare the payload and delay for each of the given jobs. + * + * @return array + */ + protected function prepareBatchJobs(array $jobs, mixed $data, ?string $queue): array + { + return Collection::make($jobs) + ->map(function (object|string $job) use ($data, $queue): array { $delay = is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null; - return $this->buildDatabaseRecord( - $queue, - $this->createPayload($job, $this->getQueue($queue), $data), - $delay !== null ? $this->availableAt($delay) : $now, - ); + return [ + 'job' => $job, + 'delay' => $delay, + 'payload' => $this->createPayload($job, $this->getQueue($queue), $data, $delay), + ]; + }) + ->all(); + } + + /** + * Insert a prepared batch and raise its queue lifecycle events. + */ + protected function enqueueBatch(array $jobs, ?string $queue): mixed + { + foreach ($jobs as $job) { + $this->raiseJobQueueingEvent($queue, $job['job'], $job['payload'], $job['delay']); + } + + try { + $now = $this->availableAt(); + + $result = $this->getDatabase()->table($this->table)->insert( + Collection::make($jobs) + ->map(fn (array $job): array => $this->buildDatabaseRecord( + $this->getQueue($queue), + $job['payload'], + $job['delay'] !== null ? $this->availableAt($job['delay']) : $now, + )) + ->all() + ); + } catch (Throwable $exception) { + foreach ($jobs as $job) { + $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception); } - )->all()); + + throw $exception; + } + + foreach ($jobs as $job) { + $this->raiseJobQueuedEvent($queue, null, $job['job'], $job['payload'], $job['delay']); + } + + return $result; } /** diff --git a/src/queue/src/Events/JobQueueingFailed.php b/src/queue/src/Events/JobQueueingFailed.php new file mode 100644 index 000000000..f8608ccef --- /dev/null +++ b/src/queue/src/Events/JobQueueingFailed.php @@ -0,0 +1,34 @@ +payload, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/src/queue/src/Events/WorkerStopping.php b/src/queue/src/Events/WorkerStopping.php index 080076581..be5c8e0a0 100644 --- a/src/queue/src/Events/WorkerStopping.php +++ b/src/queue/src/Events/WorkerStopping.php @@ -13,6 +13,7 @@ class WorkerStopping * Create a new event instance. * * @param null|float|int $memoryUsage the memory usage of the worker in megabytes + * @param bool $terminatesImmediately whether the process terminates as soon as listeners return; listeners must not start cleanup that must finish before returning when this is true */ public function __construct( public int $status = 0, @@ -21,6 +22,7 @@ public function __construct( public ?int $jobsProcessed = null, public float|int|null $lastJobProcessedAt = null, public float|int|null $memoryUsage = null, + public bool $terminatesImmediately = false, ) { } } diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 8019d0713..86f4efa20 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -27,6 +27,7 @@ use Hypervel\Queue\Attributes\Tries; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Context; @@ -389,9 +390,36 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu { $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); - return tap($callback($this, $payload, $queue, $delay), function ($jobId) use ($queue, $job, $payload, $delay) { - $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); - }); + try { + $jobId = $callback($this, $payload, $queue, $delay); + } catch (Throwable $exception) { + $this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception); + + throw $exception; + } + + $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); + + return $jobId; + } + + /** + * Partition jobs by whether they should be deferred until the active transaction commits. + * + * @return array{0: array, 1: array} + */ + protected function partitionJobsByAfterCommit( + array $jobs, + ?DatabaseTransactionsManager $transactions + ): array { + if ($transactions === null || $transactions->callbackApplicableTransactions()->isEmpty()) { + return [[], $jobs]; + } + + return Collection::make($jobs) + ->partition(fn ($job) => $this->shouldDispatchAfterCommit($job)) + ->map(fn ($partition) => $partition->values()->all()) + ->all(); } /** @@ -465,6 +493,27 @@ protected function raiseJobQueueingEvent(?string $queue, object|string $job, str } } + /** + * Raise the job queueing failed event. + * + * @param Closure|object|string $job + */ + protected function raiseJobQueueingFailedEvent(?string $queue, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay, Throwable $exception): void + { + if ($this->container->bound('events')) { + /** @var EventDispatcher $events */ + $events = $this->container->make('events'); + + if (! $events->hasListeners(JobQueueingFailed::class)) { + return; + } + + $delay = ! is_null($delay) ? $this->secondsUntil($delay) : $delay; + + $events->dispatch(new JobQueueingFailed($this->connectionName, $queue, $job, $payload, $delay, $exception)); + } + } + /** * Raise the job queued event. * diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index d5e3eabe2..4280e94dd 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -283,10 +283,11 @@ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixe return null; } - /** @var DatabaseTransactionsManager $transactions */ $messages = $this->prepareBatchMessages($afterCommit, $data, $queue); + // A non-empty deferred group means partitionJobsByAfterCommit() resolved a transactions manager. foreach ($afterCommit as $job) { + /** @var DatabaseTransactionsManager $transactions */ $this->addUniqueJobRollbackCallback($transactions, $job); $this->addDebouncedJobRollbackCallback($transactions, $job); } @@ -313,25 +314,6 @@ static function (Queue $owner) use ($messages, $queue): void { return null; } - /** - * Partition jobs by whether they should be deferred until the active transaction commits. - * - * @return array{0: array, 1: array} - */ - protected function partitionJobsByAfterCommit( - array $jobs, - ?DatabaseTransactionsManager $transactions - ): array { - if ($transactions === null || $transactions->callbackApplicableTransactions()->isEmpty()) { - return [[], $jobs]; - } - - return Collection::make($jobs) - ->partition(fn ($job) => $this->shouldDispatchAfterCommit($job)) - ->map(fn ($partition) => $partition->values()->all()) - ->all(); - } - /** * Create the payload for each of the given jobs. * @@ -371,8 +353,6 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $overflow = []; foreach ($messages as $id => $message) { - $this->raiseJobQueueingEvent($queue, $message['job'], $message['payload'], $message['delay']); - $entry = $this->prepareSendMessageBatchEntry($id, $message, $queue); if ($this->willOverflow($message['payload'])) { @@ -388,6 +368,15 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void // Dispatch chunks serially so later messages cannot arrive ahead of an unsent failed chunk... foreach ($this->chunkBatchEntries($entries) as $chunk) { + $attemptedMessages = []; + + foreach ($chunk as $entry) { + $message = $messages[$entry['Id']]; + $attemptedMessages[$entry['Id']] = $message; + + $this->raiseJobQueueingEvent($queue, $message['job'], $message['payload'], $message['delay']); + } + $writtenPaths = []; try { @@ -404,23 +393,30 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void } catch (Throwable $exception) { /** @var CacheRepository $store */ $this->cleanupOverflowPayloads($store, $writtenPaths); + $this->raiseBatchFailedEvents($attemptedMessages, $queue, $exception); throw $exception; } // A thrown request is ambiguous for the whole chunk, so retain its pointers. // Only explicit per-entry failures below are provably rejected. - $result = $this->sqs->sendMessageBatch([ - 'QueueUrl' => $queueUrl, - 'Entries' => $chunk, - ]); + try { + $result = $this->sqs->sendMessageBatch([ + 'QueueUrl' => $queueUrl, + 'Entries' => $chunk, + ]); + } catch (Throwable $exception) { + $this->raiseBatchFailedEvents($attemptedMessages, $queue, $exception); + + throw $exception; + } foreach ($result['Successful'] ?? [] as $success) { - if (! isset($messages[$success['Id']])) { + if (! isset($attemptedMessages[$success['Id']])) { continue; } - $message = $messages[$success['Id']]; + $message = $attemptedMessages[$success['Id']]; $this->raiseJobQueuedEvent( $queue, @@ -464,10 +460,36 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void $this->cleanupOverflowPayloads($store, $rejectedPaths); } + $failedMessages = []; + + foreach ($result['Failed'] as $rejected) { + if (isset($attemptedMessages[$rejected['Id']])) { + $failedMessages[$rejected['Id']] = $attemptedMessages[$rejected['Id']]; + } + } + + $this->raiseBatchFailedEvents($failedMessages, $queue, $exception); + throw $exception; } } + /** + * Raise queueing-failed events for the given prepared messages. + */ + protected function raiseBatchFailedEvents(array $messages, ?string $queue, Throwable $exception): void + { + foreach ($messages as $message) { + $this->raiseJobQueueingFailedEvent( + $queue, + $message['job'], + $message['payload'], + $message['delay'], + $exception, + ); + } + } + /** * Build the SendMessageBatch entry for a prepared message. * diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 31d3c0dae..e930a8f3a 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -1133,6 +1133,7 @@ public function stop(int $status = 0, ?WorkerOptions $options = null, ?WorkerSto $this->jobsProcessed, $this->lastJobProcessedAt, $this->currentMemoryUsage(), + terminatesImmediately: false, )); return $status; @@ -1153,6 +1154,7 @@ public function kill( $this->jobsProcessed, $this->lastJobProcessedAt, $this->currentMemoryUsage(), + terminatesImmediately: true, )); $this->terminateProcess($status); diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 8a7168898..1bff05211 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -4,15 +4,22 @@ namespace Hypervel\Tests\Queue; +use Closure; use DateInterval; use DateTimeInterface; use Hypervel\Bus\Batchable; use Hypervel\Container\Container; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Query\Builder; +use Hypervel\Events\Dispatcher; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\DatabaseQueue; +use Hypervel\Queue\Events\JobQueued; +use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\InvalidPayloadException; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Queue; @@ -22,6 +29,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use RuntimeException; use stdClass; class QueueDatabaseQueueUnitTest extends TestCase @@ -201,6 +209,7 @@ public function testBulkBatchPushesOntoDatabase(): void currentTime: 1732502704, availableAt: 1732502704, ); + $queue->setContainer(new Container); $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->andReturn($connection); @@ -238,6 +247,7 @@ public function testBulkHonorsTheDelayAttribute(): void default: 'default', currentTime: 1732502704, ); + $queue->setContainer(new Container); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(ConnectionInterface::class)); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insert') @@ -251,6 +261,196 @@ public function testBulkHonorsTheDelayAttribute(): void $queue->bulk([new DatabaseBulkAttributeDelayJob]); } + public function testBulkRaisesExactSuccessEventsAroundOneInsert(): void + { + $queue = new TestDatabaseQueue( + resolver: $resolver = m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + $dispatcher = new Dispatcher($container = new Container); + $container->instance('events', $dispatcher); + $queue->setContainer($container); + $queue->setConnectionName('database'); + + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); + $query->shouldReceive('insert')->once()->andReturnTrue(); + + $events = []; + $dispatcher->listen(JobQueueing::class, static function (JobQueueing $event) use (&$events): void { + $events[] = $event; + }); + $dispatcher->listen(JobQueued::class, static function (JobQueued $event) use (&$events): void { + $events[] = $event; + }); + + $this->assertTrue($queue->bulk(['first', 'second'], queue: 'emails')); + $this->assertSame([ + JobQueueing::class, + JobQueueing::class, + JobQueued::class, + JobQueued::class, + ], array_map(static fn (object $event): string => $event::class, $events)); + $this->assertSame(['first', 'second', 'first', 'second'], array_column($events, 'job')); + $this->assertNull($events[2]->id); + $this->assertNull($events[3]->id); + } + + public function testBulkRaisesExactFailureEventsWhenInsertThrows(): void + { + $queue = new TestDatabaseQueue( + resolver: $resolver = m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + $dispatcher = new Dispatcher($container = new Container); + $container->instance('events', $dispatcher); + $queue->setContainer($container); + $queue->setConnectionName('database'); + + $exception = new RuntimeException('Insert failed.'); + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once(); + $query->shouldReceive('insert')->once()->andThrow($exception); + + $events = []; + $dispatcher->listen(JobQueueing::class, static function (JobQueueing $event) use (&$events): void { + $events[] = $event; + }); + $dispatcher->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$events): void { + $events[] = $event; + }); + + try { + $queue->bulk(['first', 'second'], queue: 'emails'); + $this->fail('Expected the bulk insert to fail.'); + } catch (RuntimeException $actual) { + $this->assertSame($exception, $actual); + } + + $this->assertSame([ + JobQueueing::class, + JobQueueing::class, + JobQueueingFailed::class, + JobQueueingFailed::class, + ], array_map(static fn (object $event): string => $event::class, $events)); + $this->assertSame(['first', 'second', 'first', 'second'], array_column($events, 'job')); + $this->assertSame($exception, $events[2]->exception); + $this->assertSame($exception, $events[3]->exception); + } + + public function testBulkSplitsImmediateAndAfterCommitJobsAndReacquiresTheQueue(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestamp(1732502704)); + + $queue = new TestDatabaseQueue( + resolver: $immediateResolver = m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + $deferredQueue = new TestDatabaseQueue( + resolver: $deferredResolver = m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502804, + ); + + $transactions = new DatabaseTransactionsManager; + $transactions->begin('default', 1); + $container = new Container; + $container->instance('db.transactions', $transactions); + $queue->setContainer($container); + $deferredQueue->setContainer(new Container); + + $immediateConnection = m::mock(ConnectionInterface::class); + $immediateConnection->shouldReceive('table')->with('table')->andReturn($immediateQuery = m::mock(Builder::class)); + $immediateResolver->shouldReceive('connection')->with(null)->andReturn($immediateConnection)->once(); + $immediateRecords = []; + $immediateQuery->shouldReceive('insert')->once()->andReturnUsing(static function (array $records) use (&$immediateRecords): bool { + $immediateRecords = $records; + + return true; + }); + + $deferredConnection = m::mock(ConnectionInterface::class); + $deferredConnection->shouldReceive('table')->with('table')->andReturn($deferredQuery = m::mock(Builder::class)); + $deferredResolver->shouldReceive('connection')->with(null)->andReturn($deferredConnection)->once(); + $deferredRecords = []; + $deferredQuery->shouldReceive('insert')->once()->andReturnUsing(static function (array $records) use (&$deferredRecords): bool { + $deferredRecords = $records; + + return true; + }); + + $reacquired = false; + $queue->setAfterCommitDispatcher(static function (Closure $callback) use ($deferredQueue, &$reacquired): mixed { + $reacquired = true; + + return $callback($deferredQueue); + }); + + $this->assertTrue($queue->bulk(['immediate', new DatabaseBulkAfterCommitDelayJob], queue: 'emails')); + $this->assertFalse($reacquired); + $this->assertCount(1, $immediateRecords); + $this->assertSame(1732502704, $immediateRecords[0]['available_at']); + $this->assertSame([], $deferredRecords); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestamp(1732502804)); + $transactions->commit('default', 1, 0); + + $this->assertTrue($reacquired); + $this->assertCount(1, $deferredRecords); + $this->assertSame(1732502813, $deferredRecords[0]['available_at']); + $this->assertSame(9, json_decode($deferredRecords[0]['payload'], true)['delay']); + $this->assertSame(1732502704, json_decode($deferredRecords[0]['payload'], true)['createdAt']); + } + + public function testBulkRollbackDoesNotBeginAnEnqueueAttempt(): void + { + $queue = new TestDatabaseQueue( + resolver: $resolver = m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + $transactions = new DatabaseTransactionsManager; + $transactions->begin('default', 1); + $dispatcher = new Dispatcher($container = new Container); + $container->instance('db.transactions', $transactions); + $container->instance('events', $dispatcher); + $queue->setContainer($container); + + $resolver->shouldNotReceive('connection'); + $dispatched = []; + $dispatcher->listen(JobQueueing::class, static function (JobQueueing $event) use (&$dispatched): void { + $dispatched[] = $event; + }); + + $reacquired = false; + $queue->setAfterCommitDispatcher(static function () use (&$reacquired): never { + $reacquired = true; + + throw new RuntimeException('The queue must not be reacquired after rollback.'); + }); + + $this->assertNull($queue->bulk([new DatabaseBulkAfterCommitDelayJob], queue: 'emails')); + $transactions->rollback('default', 0); + + $this->assertFalse($reacquired); + $this->assertSame([], $dispatched); + } + public function testBuildDatabaseRecordWithPayloadAtTheEnd() { $queue = m::mock(DatabaseQueue::class); @@ -488,6 +688,11 @@ class DatabaseBulkAttributeDelayJob { } +#[Delay(9)] +class DatabaseBulkAfterCommitDelayJob implements ShouldQueueAfterCommit +{ +} + class TestDatabaseQueue extends DatabaseQueue { public function __construct( diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 19c3427b3..b3684735c 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -12,6 +12,7 @@ use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\LuaScripts; use Hypervel\Queue\Queue; @@ -22,6 +23,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use Override; +use RuntimeException; use Symfony\Component\Uid\Uuid; class QueueRedisQueueTest extends TestCase @@ -158,6 +160,58 @@ public function testJobQueueingAndQueuedEventsAreSkippedWhenNoListenersAreRegist $this->assertSame('foo', $id); } + public function testPushRaisesFailedEventWhenRedisThrows(): void + { + $now = CarbonImmutable::now(); + CarbonImmutable::setTestNow($now); + $uuid = $this->mockUuid(); + $exception = new RuntimeException('Redis unavailable.'); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); + $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); + $queue->setContainer($container = m::mock(Container::class)); + $queue->setConnectionName('default'); + + $redisProxy = m::mock(RedisProxy::class); + $redisProxy->shouldReceive('isCluster')->once()->andReturnFalse(); + $redisProxy->shouldReceive('eval')->once()->andThrow($exception); + $redis->shouldReceive('connection')->twice()->andReturn($redisProxy); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->with(JobQueueing::class)->andReturnTrue()->once(); + $events->shouldReceive('hasListeners')->with(JobQueueingFailed::class)->andReturnTrue()->once(); + $events->shouldReceive('dispatch') + ->withArgs(function (JobQueueing $event) use ($uuid, $now): bool { + $this->assertSame('foo', $event->job); + $this->assertSame('default', $event->connectionName); + $this->assertNull($event->queue); + $this->assertSame(['uuid' => (string) $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'id' => 'foo', 'attempts' => 0, 'delay' => null], $event->payload()); + + return true; + }) + ->ordered() + ->once(); + $events->shouldReceive('dispatch') + ->withArgs(function (JobQueueingFailed $event) use ($exception): bool { + $this->assertSame('foo', $event->job); + $this->assertSame('default', $event->connectionName); + $this->assertNull($event->queue); + $this->assertNull($event->delay); + $this->assertSame($exception, $event->exception); + + return true; + }) + ->ordered() + ->once(); + + $container->shouldReceive('bound')->with('events')->andReturnTrue()->twice(); + $container->shouldReceive('make')->with('events')->andReturn($events)->twice(); + + $this->expectExceptionObject($exception); + + $queue->push('foo', ['data']); + } + public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook(): void { $now = CarbonImmutable::now(); diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 87190a685..64b67523f 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -18,6 +18,7 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Jobs\SqsJob; use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\SqsQueue; @@ -1345,11 +1346,12 @@ public function testQueueableOptionsPreserveZeroFifoIdentifiers(): void ); } - public function testBulkRaisesQueuedEventsOnlyForSuccessfulEntries(): void + public function testBulkRaisesExactEventsForSuccessfulAndRejectedEntries(): void { $events = m::mock(EventDispatcher::class); $events->shouldReceive('hasListeners')->with(JobQueueing::class)->andReturnTrue(); $events->shouldReceive('hasListeners')->with(JobQueued::class)->andReturnTrue(); + $events->shouldReceive('hasListeners')->with(JobQueueingFailed::class)->andReturnTrue(); $dispatched = []; $events->shouldReceive('dispatch')->andReturnUsing( function (object $event) use (&$dispatched): object { @@ -1376,19 +1378,28 @@ function (object $event) use (&$dispatched): object { 'Failed' => [['Id' => '1', 'Code' => 'InternalError', 'Message' => 'failed']], ])); + $exception = null; + try { $queue->bulk(['a', 'b'], 'data', $this->queueName); $this->fail('The partial batch failure was not thrown.'); - } catch (SqsException $exception) { - $this->assertSame('InternalError', $exception->getAwsErrorCode()); + } catch (SqsException $actual) { + $exception = $actual; + $this->assertSame('InternalError', $actual->getAwsErrorCode()); } $queueing = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueing)); $queued = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueued)); + $failed = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueingFailed)); $this->assertCount(2, $queueing); $this->assertCount(1, $queued); + $this->assertCount(1, $failed); $this->assertSame('successful-id', $queued[0]->id); + $this->assertSame('p1', $queued[0]->payload); + $this->assertSame('b', $failed[0]->job); + $this->assertSame('p2', $failed[0]->payload); + $this->assertSame($exception, $failed[0]->exception); } public function testBulkCleansOnlyRejectedOverflowPointers(): void @@ -1453,8 +1464,21 @@ public function testBulkCleansEarlierWritesWhenAWriteFailsBeforeSending(): void $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); + $events = m::mock(EventDispatcher::class); + $events->shouldReceive('hasListeners')->with(JobQueueing::class)->andReturnTrue(); + $events->shouldReceive('hasListeners')->with(JobQueueingFailed::class)->andReturnTrue(); + $dispatched = []; + $events->shouldReceive('dispatch')->andReturnUsing( + static function (object $event) use (&$dispatched): object { + $dispatched[] = $event; + + return $event; + } + ); + $container = new Container; $container->instance('cache', $cache); + $container->instance('events', $events); $queue = $this->getMockBuilder(SqsQueue::class) ->onlyMethods(['getQueue', 'createPayload']) @@ -1468,6 +1492,7 @@ public function testBulkCleansEarlierWritesWhenAWriteFailsBeforeSending(): void ]) ->getMock(); $queue->setContainer($container); + $queue->setConnectionName('sqs'); $queue->expects($this->once())->method('getQueue')->willReturn($this->queueUrl); $queue->expects($this->exactly(2))->method('createPayload')->willReturnOnConsecutiveCalls( $firstPayload, @@ -1476,10 +1501,19 @@ public function testBulkCleansEarlierWritesWhenAWriteFailsBeforeSending(): void $this->sqs->shouldNotReceive('sendMessageBatch'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to store the SQS overflow payload'); + try { + $queue->bulk(['a', 'b'], 'data', $this->queueName); + $this->fail('Expected overflow storage to fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Unable to store the SQS overflow payload', $exception->getMessage()); + } - $queue->bulk(['a', 'b'], 'data', $this->queueName); + $queueing = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueing)); + $failed = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueingFailed)); + + $this->assertSame(['a', 'b'], array_column($queueing, 'job')); + $this->assertSame(['a', 'b'], array_column($failed, 'job')); + $this->assertSame($failed[0]->exception, $failed[1]->exception); } public function testBulkRetainsAmbiguousChunkPointersAndNeverWritesLaterChunks(): void @@ -1491,8 +1525,21 @@ public function testBulkRetainsAmbiguousChunkPointersAndNeverWritesLaterChunks() $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); + $events = m::mock(EventDispatcher::class); + $events->shouldReceive('hasListeners')->with(JobQueueing::class)->andReturnTrue(); + $events->shouldReceive('hasListeners')->with(JobQueueingFailed::class)->andReturnTrue(); + $dispatched = []; + $events->shouldReceive('dispatch')->andReturnUsing( + static function (object $event) use (&$dispatched): object { + $dispatched[] = $event; + + return $event; + } + ); + $container = new Container; $container->instance('cache', $cache); + $container->instance('events', $events); $queue = $this->getMockBuilder(SqsQueue::class) ->onlyMethods(['getQueue', 'createPayload']) @@ -1506,6 +1553,7 @@ public function testBulkRetainsAmbiguousChunkPointersAndNeverWritesLaterChunks() ]) ->getMock(); $queue->setContainer($container); + $queue->setConnectionName('sqs'); $queue->expects($this->once())->method('getQueue')->willReturn($this->queueUrl); $queue->method('createPayload')->willReturnCallback( static fn ($job): string => json_encode(['uuid' => "job-{$job}"], JSON_THROW_ON_ERROR) @@ -1513,10 +1561,19 @@ public function testBulkRetainsAmbiguousChunkPointersAndNeverWritesLaterChunks() $this->sqs->shouldReceive('sendMessageBatch')->once()->andThrow(new RuntimeException('transport failed')); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('transport failed'); + try { + $queue->bulk(array_map('strval', range(1, 11)), 'data', $this->queueName); + $this->fail('Expected the SQS request to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('transport failed', $exception->getMessage()); + } + + $queueing = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueing)); + $failed = array_values(array_filter($dispatched, static fn ($event) => $event instanceof JobQueueingFailed)); - $queue->bulk(array_map('strval', range(1, 11)), 'data', $this->queueName); + $this->assertSame(array_map('strval', range(1, 10)), array_column($queueing, 'job')); + $this->assertSame(array_map('strval', range(1, 10)), array_column($failed, 'job')); + $this->assertSame($failed[0]->exception, $failed[9]->exception); } public function testBulkDefersOnePreparedBatchUntilTheTransactionCommits(): void diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index fb89d7cd2..eeca5599f 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -341,6 +341,7 @@ public function testTimeoutMonitorUsesTheDefaultErrorExitAndTimedOutReason(): vo $this->events->shouldHaveReceived('dispatch')->with(m::on( static fn (object $event): bool => $event instanceof WorkerStopping && $event->reason === WorkerStopReason::TimedOut + && $event->terminatesImmediately ))->once(); } @@ -421,7 +422,10 @@ public function testKillDoesNotWaitForUnrelatedActiveJobs(): void } $this->assertNull($worker->sleptFor); - $this->events->shouldHaveReceived('dispatch')->with(m::type(WorkerStopping::class))->once(); + $this->events->shouldHaveReceived('dispatch')->with(m::on( + static fn (object $event): bool => $event instanceof WorkerStopping + && $event->terminatesImmediately + ))->once(); } public function testWorkerCanWorkUntilQueueIsEmpty() @@ -1050,7 +1054,8 @@ public function testWorkerStoppingIsDispatched() && $event->reason === WorkerStopReason::QueueEmpty && $event->jobsProcessed === 2 && $event->lastJobProcessedAt !== null - && $event->memoryUsage > 0; + && $event->memoryUsage > 0 + && ! $event->terminatesImmediately; })); } From 87e898c93d00b28c5b4884e060a8c83c37c1d184 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:00:01 +0000 Subject: [PATCH 08/18] Make Sentry transport ownership exact Replace the synthetic HTTP client with the SDK client and give each detached send exclusive ownership of one borrowed pooled transport. Balance one captured WaitGroup generation across release, discard, and coroutine-spawn failure so bounded drains never wait on later sends.\n\nKeep ordinary flushes nonblocking, flush buffered logs and metrics before transport capture, reserve pool shutdown for worker exit, and preserve fail-fast backpressure. Cover event ids, status and rate-limit handling, exhaustion, generation swaps, child failures, and shutdown. --- src/sentry/src/HttpClient/HttpClient.php | 20 --- src/sentry/src/Integration.php | 43 ++++-- .../src/Transport/HttpPoolTransport.php | 100 +++++++------- src/sentry/src/Transport/Pool.php | 6 +- tests/Sentry/HttpPoolTransportTest.php | 130 ++++++++++++++---- tests/Sentry/PoolTest.php | 127 +++++++++++++++++ 6 files changed, 320 insertions(+), 106 deletions(-) delete mode 100644 src/sentry/src/HttpClient/HttpClient.php create mode 100644 tests/Sentry/PoolTest.php diff --git a/src/sentry/src/HttpClient/HttpClient.php b/src/sentry/src/HttpClient/HttpClient.php deleted file mode 100644 index 3375ad5bc..000000000 --- a/src/sentry/src/HttpClient/HttpClient.php +++ /dev/null @@ -1,20 +0,0 @@ - parent::sendRequest($request, $options)); - - return new Response(202, ['X-Sentry-Request-Status' => ['Queued']], ''); - } -} diff --git a/src/sentry/src/Integration.php b/src/sentry/src/Integration.php index 5fa09da33..e7b3662d6 100644 --- a/src/sentry/src/Integration.php +++ b/src/sentry/src/Integration.php @@ -19,6 +19,8 @@ use Sentry\SentrySdk; use Sentry\State\Scope; use Sentry\Tracing\TransactionSource; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; use Throwable; use function Sentry\addBreadcrumb; @@ -113,21 +115,19 @@ public static function setTransaction(?string $transaction): void } /** - * Block until all events are processed by the PHP SDK client. - * - * @internal this is not part of the public API and is here temporarily until - * the underlying issue can be resolved, this method will be removed + * Flush buffered events without waiting for delivery. */ public static function flushEvents(): void { - $client = SentrySdk::getCurrentHub()->getClient(); - - if ($client !== null) { - $client->flush(); + self::flush(null, false); + } - Logs::getInstance()->flush(); - TraceMetrics::getInstance()->flush(); - } + /** + * Flush buffered events and wait for the captured delivery generation. + */ + public static function drainEvents(?int $timeout = null): Result + { + return self::flush($timeout, true); } /** @@ -222,6 +222,27 @@ 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/Transport/HttpPoolTransport.php b/src/sentry/src/Transport/HttpPoolTransport.php index 1f0fdf631..ce3b6cec3 100644 --- a/src/sentry/src/Transport/HttpPoolTransport.php +++ b/src/sentry/src/Transport/HttpPoolTransport.php @@ -4,8 +4,8 @@ namespace Hypervel\Sentry\Transport; -use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\WaitGroup; use RuntimeException; use Sentry\Event; use Sentry\Transport\HttpTransport; @@ -16,13 +16,11 @@ class HttpPoolTransport implements TransportInterface { - /** - * Context key for the per-coroutine list of checked-out transports. - */ - public const CONTEXT_TRANSPORTS_KEY = '__sentry.transports'; + protected WaitGroup $group; public function __construct(protected Pool $pool) { + $this->group = new WaitGroup; } /** @@ -30,7 +28,6 @@ public function __construct(protected Pool $pool) * * Checks out a transport from the pool. If the pool is exhausted, the event * is silently dropped (backpressure) to avoid blocking the request coroutine. - * All checked-out transports are tracked per-coroutine and released on close(). */ public function send(Event $event): Result { @@ -42,67 +39,76 @@ public function send(Event $event): Result return new Result(ResultStatus::skipped()); } - $transports = $this->initializeTrackedTransports(); - $transports[] = $transport; - CoroutineContext::set(self::CONTEXT_TRANSPORTS_KEY, $transports); + // Capture the generation once: a concurrent drain may replace $this->group, + // but this send must call done() on the same group it increments. + $group = $this->group; + $group->add(); try { - return $transport->send($event); + $this->createCoroutine(function () use ($event, $group, $transport): void { + $discard = false; + + try { + $transport->send($event); + } catch (Throwable) { + $discard = true; + } finally { + try { + if ($discard) { + $this->pool->discard($transport); + } else { + $this->pool->release($transport); + } + } finally { + $group->done(); + } + } + }); } catch (Throwable) { - $transports = CoroutineContext::get(self::CONTEXT_TRANSPORTS_KEY, []); - if (($key = array_search($transport, $transports, true)) !== false) { - unset($transports[$key]); - CoroutineContext::set(self::CONTEXT_TRANSPORTS_KEY, array_values($transports)); + try { + $this->pool->release($transport); + } finally { + $group->done(); } - // A transport that failed mid-send may retain corrupt or pending - // state and must never be handed to another coroutine. - $this->pool->discard($transport); - return new Result(ResultStatus::failed()); } + + return new Result(ResultStatus::success(), $event); } /** - * Release all checked-out transports back to the pool. - * - * Called by Integration::flushEvents() → Client::flush() at the end - * of each request lifecycle via FlushEventsMiddleware::terminate(). + * Observe or wait for accepted sends to complete. */ public function close(?int $timeout = null): Result { - foreach (CoroutineContext::get(self::CONTEXT_TRANSPORTS_KEY, []) as $transport) { - $this->pool->release($transport); + if ($timeout === null || $timeout <= 0) { + return new Result($this->group->count() === 0 + ? ResultStatus::success() + : ResultStatus::unknown()); } - CoroutineContext::set(self::CONTEXT_TRANSPORTS_KEY, []); - return new Result(ResultStatus::success()); + $group = $this->group; + $this->group = new WaitGroup; + + return new Result($group->wait($timeout) + ? ResultStatus::success() + : ResultStatus::unknown()); } /** - * Get the tracked transport list, registering a coroutine defer callback - * on first access to ensure transports are released even if the coroutine - * dies without close() being called. - * - * @return list + * Close the underlying transport pool. */ - private function initializeTrackedTransports(): array + public function shutdown(): void { - $transports = CoroutineContext::get(self::CONTEXT_TRANSPORTS_KEY); - - if (is_array($transports)) { - return $transports; - } - - CoroutineContext::set(self::CONTEXT_TRANSPORTS_KEY, []); - - Coroutine::defer(function (): void { - foreach (CoroutineContext::get(self::CONTEXT_TRANSPORTS_KEY, []) as $transport) { - $this->pool->release($transport); - } - CoroutineContext::set(self::CONTEXT_TRANSPORTS_KEY, []); - }); + $this->pool->close(); + } - return []; + /** + * Create the coroutine that owns a checked-out transport. + */ + protected function createCoroutine(callable $callback): void + { + Coroutine::create($callback); } } diff --git a/src/sentry/src/Transport/Pool.php b/src/sentry/src/Transport/Pool.php index a56134d00..ec5989b47 100644 --- a/src/sentry/src/Transport/Pool.php +++ b/src/sentry/src/Transport/Pool.php @@ -6,8 +6,8 @@ use Hypervel\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolOptions; -use Hypervel\Sentry\HttpClient\HttpClient; -use Sentry\Client as SentryClient; +use Hypervel\Sentry\Version; +use Sentry\HttpClient\HttpClient; use Sentry\HttpClient\HttpClientInterface; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; @@ -37,6 +37,6 @@ protected function createObject(): HttpTransport protected function getHttpClient(): HttpClientInterface { - return new HttpClient(SentryClient::SDK_IDENTIFIER, SentryClient::SDK_VERSION); + return new HttpClient(Version::getSdkIdentifier(), Version::getSdkVersion()); } } diff --git a/tests/Sentry/HttpPoolTransportTest.php b/tests/Sentry/HttpPoolTransportTest.php index de2c58f95..bf4854cfa 100644 --- a/tests/Sentry/HttpPoolTransportTest.php +++ b/tests/Sentry/HttpPoolTransportTest.php @@ -47,7 +47,7 @@ public function testBackpressureDoesNotBlockOnPoolExhaustion(): void $this->assertSame(ResultStatus::skipped(), $result->getStatus()); } - public function testSingleSendThenCloseReleasesTransport(): void + public function testAcceptedSendReturnsItsEventAndReleasesTransportAfterCompletion(): void { $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldReceive('send') @@ -64,8 +64,12 @@ public function testSingleSendThenCloseReleasesTransport(): void $transport = new HttpPoolTransport($pool); - $transport->send(Event::createEvent()); - $transport->close(); + $event = Event::createEvent(); + $result = $transport->send($event); + + $this->assertSame(ResultStatus::success(), $result->getStatus()); + $this->assertSame($event, $result->getEvent()); + $this->assertSame(ResultStatus::success(), $transport->close()->getStatus()); } public function testMultipleSendsThenCloseReleasesAllTransports(): void @@ -126,7 +130,7 @@ public function testThreeSendsThenCloseReleasesAllTransports(): void $transport->close(); } - public function testSendExceptionDiscardsTransportImmediately(): void + public function testUnexpectedChildFailureDiscardsTransport(): void { $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldReceive('send') @@ -137,18 +141,18 @@ public function testSendExceptionDiscardsTransportImmediately(): void $pool->shouldReceive('get') ->once() ->andReturn($httpTransport); - // Discarded immediately on exception, not tracked for close(). $pool->shouldReceive('discard') ->once() ->with($httpTransport); $transport = new HttpPoolTransport($pool); - $result = $transport->send(Event::createEvent()); + $event = Event::createEvent(); + $result = $transport->send($event); - $this->assertSame(ResultStatus::failed(), $result->getStatus()); + $this->assertSame(ResultStatus::success(), $result->getStatus()); + $this->assertSame($event, $result->getEvent()); - // close() should not try to finalize again — already discarded. $transport->close(); } @@ -166,7 +170,7 @@ public function testFailedTransportIsReplacedOnTheNextBorrow(): void $pool->shouldReceive('release')->once()->with($replacement); $transport = new HttpPoolTransport($pool); - $this->assertSame(ResultStatus::failed(), $transport->send(Event::createEvent())->getStatus()); + $this->assertSame(ResultStatus::success(), $transport->send(Event::createEvent())->getStatus()); $this->assertSame(ResultStatus::success(), $transport->send(Event::createEvent())->getStatus()); $transport->close(); } @@ -196,7 +200,6 @@ public function testMixedSuccessAndFailureFinalizesCorrectly(): void $pool->shouldReceive('discard') ->once() ->with($httpTransport2); - // transport1 and transport3 released on close() $pool->shouldReceive('release') ->once() ->with($httpTransport1); @@ -212,10 +215,87 @@ public function testMixedSuccessAndFailureFinalizesCorrectly(): void $transport->close(); } + public function testPositiveCloseWaitsOnlyForItsCapturedGeneration(): void + { + $firstStarted = new Channel(1); + $secondStarted = new Channel(1); + $releaseFirst = new Channel(1); + $releaseSecond = new Channel(1); + $drainResult = new Channel(1); + + $first = m::mock(HttpTransport::class); + $first->shouldReceive('send')->once()->andReturnUsing( + static function () use ($firstStarted, $releaseFirst): Result { + $firstStarted->push(true); + $releaseFirst->pop(); + + return new Result(ResultStatus::success()); + } + ); + $second = m::mock(HttpTransport::class); + $second->shouldReceive('send')->once()->andReturnUsing( + static function () use ($secondStarted, $releaseSecond): Result { + $secondStarted->push(true); + $releaseSecond->pop(); + + return new Result(ResultStatus::success()); + } + ); + + $pool = m::mock(Pool::class); + $pool->shouldReceive('get')->twice()->andReturn($first, $second); + $pool->shouldReceive('release')->once()->with($first); + $pool->shouldReceive('release')->once()->with($second); + + $transport = new HttpPoolTransport($pool); + $transport->send(Event::createEvent()); + $this->assertTrue($firstStarted->pop(1.0)); + + Coroutine::create(static function () use ($drainResult, $transport): void { + $drainResult->push($transport->close(1)); + }); + + $transport->send(Event::createEvent()); + $this->assertTrue($secondStarted->pop(1.0)); + + $releaseFirst->push(true); + $firstResult = $drainResult->pop(1.0); + + $this->assertInstanceOf(Result::class, $firstResult); + $this->assertSame(ResultStatus::success(), $firstResult->getStatus()); + $this->assertSame(ResultStatus::unknown(), $transport->close()->getStatus()); + + $releaseSecond->push(true); + $this->assertSame(ResultStatus::success(), $transport->close(1)->getStatus()); + } + + public function testCoroutineCreationFailureBalancesTheGenerationAndReleasesTheTransport(): void + { + $httpTransport = m::mock(HttpTransport::class); + $httpTransport->shouldNotReceive('send'); + + $pool = m::mock(Pool::class); + $pool->shouldReceive('get')->once()->andReturn($httpTransport); + $pool->shouldReceive('release')->once()->with($httpTransport); + + $transport = new FailingCoroutineHttpPoolTransport($pool); + $result = $transport->send(Event::createEvent()); + + $this->assertSame(ResultStatus::failed(), $result->getStatus()); + $this->assertSame(ResultStatus::success(), $transport->close()->getStatus()); + } + + public function testShutdownClosesThePool(): void + { + $pool = m::mock(Pool::class); + $pool->shouldReceive('close')->once(); + + (new HttpPoolTransport($pool))->shutdown(); + } + public function testCloseWithNoSendsDoesNothing(): void { $pool = m::mock(Pool::class); - // release should never be called $pool->shouldNotReceive('release'); $transport = new HttpPoolTransport($pool); @@ -225,7 +305,7 @@ public function testCloseWithNoSendsDoesNothing(): void $this->assertSame(ResultStatus::success(), $result->getStatus()); } - public function testCloseAfterCloseDoesNotDoubleRelease(): void + public function testRepeatedCloseDoesNotReleaseACompletedSendAgain(): void { $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldReceive('send') @@ -236,7 +316,6 @@ public function testCloseAfterCloseDoesNotDoubleRelease(): void $pool->shouldReceive('get') ->once() ->andReturn($httpTransport); - // Should only be released once across both close() calls $pool->shouldReceive('release') ->once() ->with($httpTransport); @@ -245,10 +324,10 @@ public function testCloseAfterCloseDoesNotDoubleRelease(): void $transport->send(Event::createEvent()); $transport->close(); - $transport->close(); // Second close should be a no-op + $transport->close(); } - public function testTransportsAreReleasedWhenCoroutineDiesWithoutClose(): void + public function testChildReleasesTransportWithoutARequestClose(): void { $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldReceive('send') @@ -270,19 +349,16 @@ public function testTransportsAreReleasedWhenCoroutineDiesWithoutClose(): void $transport = new HttpPoolTransport($pool); - // Run send() in a child coroutine that exits WITHOUT calling close() Coroutine::create(function () use ($transport): void { $transport->send(Event::createEvent()); - // Coroutine ends here — no close() called }); - // Wait for the deferred release to fire (with timeout) $wasReleased = $released->pop(1.0); - $this->assertTrue($wasReleased, 'Transport should be released via defer when coroutine exits without close()'); + $this->assertTrue($wasReleased); } - public function testDeferDoesNotDoubleReleaseWhenCloseAlreadyCalled(): void + public function testCloseDoesNotReleaseAnAlreadyCompletedSendAgain(): void { $httpTransport = m::mock(HttpTransport::class); $httpTransport->shouldReceive('send') @@ -305,18 +381,22 @@ public function testDeferDoesNotDoubleReleaseWhenCloseAlreadyCalled(): void $done = new Channel(1); - // Run send() + close() in a child coroutine — defer should be a no-op Coroutine::create(function () use ($transport, $done): void { $transport->send(Event::createEvent()); $transport->close(); - // Coroutine ends — defer fires but list is empty $done->push(true); }); $done->pop(1.0); - // Give defer a moment to fire - usleep(10_000); - $this->assertSame(1, $releaseCount, 'Transport should be released exactly once — by close(), not again by defer'); + $this->assertSame(1, $releaseCount); + } +} + +class FailingCoroutineHttpPoolTransport extends HttpPoolTransport +{ + protected function createCoroutine(callable $callback): void + { + throw new RuntimeException('Unable to create coroutine.'); } } diff --git a/tests/Sentry/PoolTest.php b/tests/Sentry/PoolTest.php new file mode 100644 index 000000000..8da7315d2 --- /dev/null +++ b/tests/Sentry/PoolTest.php @@ -0,0 +1,127 @@ +shouldReceive('version') + ->once() + ->with('hypervel/sentry') + ->andReturn('1.2.3'); + + Container::getInstance()->singleton(PackageManifest::class, fn () => $manifest); + + $pool = new InspectableSentryTransportPool( + new Options, + $this->poolOptions(), + ); + + $httpClient = $pool->createHttpClient(); + + $this->assertInstanceOf(HttpClient::class, $httpClient); + $this->assertSame('sentry.php.hypervel', (new ReflectionProperty($httpClient, 'sdkIdentifier'))->getValue($httpClient)); + $this->assertSame('1.2.3', (new ReflectionProperty($httpClient, 'sdkVersion'))->getValue($httpClient)); + $pool->close(); + } + + public function testCreatesTheSdkHttpClientWithTheFrameworkVersionAsFallback(): void + { + $manifest = m::mock(PackageManifest::class); + $manifest->shouldReceive('version') + ->once() + ->with('hypervel/sentry') + ->andReturn(null); + + Container::getInstance()->singleton(PackageManifest::class, fn () => $manifest); + + $pool = new InspectableSentryTransportPool(new Options, $this->poolOptions()); + $httpClient = $pool->createHttpClient(); + + $this->assertSame(Application::VERSION, (new ReflectionProperty($httpClient, 'sdkVersion'))->getValue($httpClient)); + $pool->close(); + } + + public function testPooledTransportRetainsRateLimitsFromRealResponses(): void + { + $httpClient = m::mock(HttpClientInterface::class); + $httpClient->shouldReceive('sendRequest') + ->once() + ->andReturn(new Response(429, [ + 'X-Sentry-Rate-Limits' => ['60:error'], + ], '')); + + $pool = new ScriptedSentryTransportPool( + new Options(['dsn' => 'https://public@example.com/1']), + $this->poolOptions(), + $httpClient, + ); + + $transport = $pool->get(); + $this->assertSame(ResultStatus::rateLimit(), $transport->send(Event::createEvent())->getStatus()); + $pool->release($transport); + + $sameTransport = $pool->get(); + $this->assertSame($transport, $sameTransport); + $this->assertSame(ResultStatus::rateLimit(), $sameTransport->send(Event::createEvent())->getStatus()); + $pool->release($sameTransport); + $pool->close(); + } + + /** + * Get pool options for one reusable transport. + */ + private function poolOptions(): PoolOptions + { + return PoolOptions::fromArray([ + 'min_retained_objects' => 0, + 'max_objects' => 1, + 'wait_timeout' => 0.1, + 'max_lifetime' => 0, + 'idle_ttl' => null, + ]); + } +} + +class InspectableSentryTransportPool extends Pool +{ + public function createHttpClient(): HttpClientInterface + { + return $this->getHttpClient(); + } +} + +class ScriptedSentryTransportPool extends Pool +{ + public function __construct( + Options $sentryOptions, + PoolOptions $poolOptions, + private HttpClientInterface $httpClient, + ) { + parent::__construct($sentryOptions, $poolOptions); + } + + protected function getHttpClient(): HttpClientInterface + { + return $this->httpClient; + } +} From c674d0c1b1555127653678c3a513ddae45967081 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:00:38 +0000 Subject: [PATCH 09/18] Gate Sentry instrumentation and trace storage lazily Centralize endpoint, tracing, and breadcrumb capability decisions over merged configuration, then avoid registering aspects, listeners, middleware work, and decorators that cannot produce output. Keep propagation independent from local recording and retain per-feature memoization outside request paths.\n\nPort Storage tracing through one outer decorator while preserving logical names, pools, stream leases, temporary URL capabilities, fluent chains, and scoped configuration. Add middleware continuation control, truthful feature-phase diagnostics, dynamic SDK identity, and regression coverage for inactive boot, Spotlight, capability gating, adapter delegation, purge, routes, and storage operations. --- src/sentry/config/sentry.php | 18 +- .../src/Aspects/GuzzleHttpClientAspect.php | 63 +- src/sentry/src/Features/Feature.php | 52 +- .../Storage/CloudFilesystemDecorator.php | 18 + .../Features/Storage/DecoratedFilesystem.php | 18 + .../Storage/FilesystemAdapterDecorator.php | 149 +++++ .../Features/Storage/FilesystemDecorator.php | 334 ++++++++++ .../src/Features/Storage/Integration.php | 148 +++++ .../Storage/SentryCloudFilesystem.php | 24 + .../src/Features/Storage/SentryFilesystem.php | 24 + .../Storage/SentryFilesystemAdapter.php | 32 + .../Features/Storage/SentryS3V3Adapter.php | 32 + src/sentry/src/Http/FlushEventsMiddleware.php | 13 +- src/sentry/src/SdkCapabilities.php | 108 ++++ src/sentry/src/SentryServiceProvider.php | 127 ++-- src/sentry/src/Tracing/Middleware.php | 39 +- src/sentry/src/Version.php | 3 +- .../Aspects/GuzzleHttpClientAspectTest.php | 97 +++ .../Features/StorageIntegrationTest.php | 572 ++++++++++++++++++ .../Sentry/Http/FlushEventsMiddlewareTest.php | 55 ++ tests/Sentry/SentryTestCase.php | 26 +- ...erviceProviderListenerRegistrationTest.php | 4 + tests/Sentry/ServiceProviderTest.php | 190 +++++- .../Sentry/ServiceProviderWithoutDsnTest.php | 12 + tests/Sentry/Tracing/MiddlewareTest.php | 36 ++ 25 files changed, 2055 insertions(+), 139 deletions(-) create mode 100644 src/sentry/src/Features/Storage/CloudFilesystemDecorator.php create mode 100644 src/sentry/src/Features/Storage/DecoratedFilesystem.php create mode 100644 src/sentry/src/Features/Storage/FilesystemAdapterDecorator.php create mode 100644 src/sentry/src/Features/Storage/FilesystemDecorator.php create mode 100644 src/sentry/src/Features/Storage/Integration.php create mode 100644 src/sentry/src/Features/Storage/SentryCloudFilesystem.php create mode 100644 src/sentry/src/Features/Storage/SentryFilesystem.php create mode 100644 src/sentry/src/Features/Storage/SentryFilesystemAdapter.php create mode 100644 src/sentry/src/Features/Storage/SentryS3V3Adapter.php create mode 100644 src/sentry/src/SdkCapabilities.php create mode 100644 tests/Sentry/Features/StorageIntegrationTest.php create mode 100644 tests/Sentry/Http/FlushEventsMiddlewareTest.php diff --git a/src/sentry/config/sentry.php b/src/sentry/config/sentry.php index 8ceee1404..b048b582a 100644 --- a/src/sentry/config/sentry.php +++ b/src/sentry/config/sentry.php @@ -9,6 +9,7 @@ use Hypervel\Sentry\Features\NotificationsFeature; use Hypervel\Sentry\Features\QueueFeature; use Hypervel\Sentry\Features\RedisFeature; +use Hypervel\Sentry\Features\Storage\Integration as StorageIntegration; use Hypervel\Validation\ValidationException; use Sentry\Integration\EnvironmentIntegration; use Sentry\Integration\FrameContextifierIntegration; @@ -21,10 +22,10 @@ */ return [ // @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/ - 'dsn' => env('SENTRY_HYPERVEL_DSN'), + 'dsn' => env('SENTRY_HYPERVEL_DSN', env('SENTRY_DSN')), // @see https://spotlightjs.com/ - // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + 'spotlight' => env('SENTRY_SPOTLIGHT', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` @@ -54,6 +55,9 @@ // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics + 'enable_metrics' => env('SENTRY_ENABLE_METRICS', true), + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), @@ -67,7 +71,10 @@ // 'ignore_exceptions' => [], // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions - 'ignore_transactions' => [], + 'ignore_transactions' => [ + // Ignore the conventional health route path + '/up', + ], // Breadcrumb specific configuration 'breadcrumbs' => [ @@ -140,8 +147,8 @@ // Enable tracing for requests without a matching route (404's) 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), - // Enable the tracing integrations supplied by Sentry (recommended) - 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + // Continue the trace through after-response work before finishing the transaction + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), ], /* @@ -171,6 +178,7 @@ ConsoleIntegration::class, ConsoleSchedulingFeature::class, RedisFeature::class, + StorageIntegration::class, ], // Exceptions that should not be reported to Sentry diff --git a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php index 4d7eaed23..25e7943cf 100644 --- a/src/sentry/src/Aspects/GuzzleHttpClientAspect.php +++ b/src/sentry/src/Aspects/GuzzleHttpClientAspect.php @@ -6,10 +6,13 @@ use GuzzleHttp\Client; use GuzzleHttp\TransferStats; +use Hypervel\Contracts\Config\Repository; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Sentry\Integration; +use Hypervel\Sentry\SdkCapabilities; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\UriInterface; use Sentry\Breadcrumb; use Sentry\SentrySdk; use Sentry\Tracing\Span; @@ -30,7 +33,7 @@ * - Preservation of any existing on_stats callback * - Per-request opt-out via the no_sentry_aspect option * - * This catches ALL Guzzle usage: Http:: facade, direct new Client(), + * This catches all Guzzle usage: Http:: facade, direct new Client(), * and third-party packages using Guzzle internally. */ class GuzzleHttpClientAspect extends AbstractAspect @@ -47,10 +50,13 @@ class GuzzleHttpClientAspect extends AbstractAspect * Create a new aspect instance. */ public function __construct( - private readonly \Hypervel\Contracts\Config\Repository $config, + private readonly Repository $config, + SdkCapabilities $capabilities, ) { - $this->tracingEnabled = $this->config->boolean('sentry.tracing.http_client_requests', true); - $this->breadcrumbsEnabled = $this->config->boolean('sentry.breadcrumbs.http_client_requests', true); + $this->tracingEnabled = $capabilities->canRecordSpans() + && $this->config->boolean('sentry.tracing.http_client_requests', true); + $this->breadcrumbsEnabled = $capabilities->canRecordBreadcrumbs() + && $this->config->boolean('sentry.breadcrumbs.http_client_requests', true); } /** @@ -58,14 +64,9 @@ public function __construct( */ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed { - if (! $this->tracingEnabled && ! $this->breadcrumbsEnabled) { - return $proceedingJoinPoint->process(); - } - $options = $proceedingJoinPoint->arguments['keys']['options'] ?? []; - // Check for per-request or per-client opt-out - if ($this->isOptedOut($options, $proceedingJoinPoint->getInstance())) { + if ($this->isOptedOut($options)) { return $proceedingJoinPoint->process(); } @@ -73,7 +74,7 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed $request = $proceedingJoinPoint->arguments['keys']['request']; // Inject trace headers before the request is sent - if ($this->tracingEnabled && $this->shouldAttachTracingHeaders($request)) { + if ($this->shouldAttachTracingHeaders($request)) { $request = $request ->withHeader('sentry-trace', getTraceparent()) ->withHeader('baggage', getBaggage()); @@ -82,7 +83,6 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed // Start a child span for tracing (finished in the on_stats callback) $span = null; - $parentSpan = null; if ($this->tracingEnabled) { $parentSpan = SentrySdk::getCurrentHub()->getSpan(); @@ -103,8 +103,6 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed ->setOrigin('auto.http.guzzle') ->setDescription($method . ' ' . $partialUri) ); - - SentrySdk::getCurrentHub()->setSpan($span); } } @@ -114,13 +112,17 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed $existingOnStats = $options['on_stats'] ?? null; $recordBreadcrumbs = $this->breadcrumbsEnabled; - $proceedingJoinPoint->arguments['keys']['options']['on_stats'] = static function (TransferStats $stats) use ($existingOnStats, $span, $parentSpan, $recordBreadcrumbs): void { + if ($span === null && ! $recordBreadcrumbs) { + return $proceedingJoinPoint->process(); + } + + $proceedingJoinPoint->arguments['keys']['options']['on_stats'] = static function (TransferStats $stats) use ($existingOnStats, $span, $recordBreadcrumbs): void { if ($recordBreadcrumbs) { self::recordBreadcrumb($stats); } if ($span !== null) { - self::finishSpan($span, $parentSpan, $stats); + self::finishSpan($span, $stats); } if (is_callable($existingOnStats)) { @@ -135,10 +137,6 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed if ($span !== null && $span->getEndTimestamp() === null) { $span->setStatus(SpanStatus::internalError()); $span->finish(); - - if ($parentSpan !== null) { - SentrySdk::getCurrentHub()->setSpan($parentSpan); - } } throw $exception; @@ -148,7 +146,7 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed /** * Finish the span with response data from the transfer stats. */ - private static function finishSpan(Span $span, ?Span $parentSpan, TransferStats $stats): void + private static function finishSpan(Span $span, TransferStats $stats): void { $response = $stats->getResponse(); @@ -163,10 +161,6 @@ private static function finishSpan(Span $span, ?Span $parentSpan, TransferStats } $span->finish(); - - if ($parentSpan !== null) { - SentrySdk::getCurrentHub()->setSpan($parentSpan); - } } /** @@ -222,22 +216,9 @@ private static function recordBreadcrumb(TransferStats $stats): void /** * Determine if the request has opted out of Sentry instrumentation. */ - private function isOptedOut(array $options, ?object $client): bool + private function isOptedOut(array $options): bool { - // Per-request opt-out - if (($options['no_sentry_aspect'] ?? null) === true) { - return true; - } - - // Per-client opt-out via client config - if ($client instanceof Client) { - $clientConfig = (fn () => $this->config)->call($client); - if (($clientConfig['no_sentry_aspect'] ?? null) === true) { - return true; - } - } - - return false; + return ($options['no_sentry_aspect'] ?? false) === true; } /** @@ -263,7 +244,7 @@ private function shouldAttachTracingHeaders(RequestInterface $request): bool /** * Build a partial URI string excluding query and fragment. */ - private static function buildPartialUri(\Psr\Http\Message\UriInterface $uri): string + private static function buildPartialUri(UriInterface $uri): string { $result = $uri->getScheme() . '://' . $uri->getHost(); diff --git a/src/sentry/src/Features/Feature.php b/src/sentry/src/Features/Feature.php index 76b31a0d9..e5e35c944 100644 --- a/src/sentry/src/Features/Feature.php +++ b/src/sentry/src/Features/Feature.php @@ -5,8 +5,8 @@ namespace Hypervel\Sentry\Features; use Hypervel\Contracts\Container\Container; +use Hypervel\Sentry\SdkCapabilities; use Sentry\SentrySdk; -use Throwable; /** * @internal @@ -27,6 +27,10 @@ abstract class Feature */ private array $isBreadcrumbFeatureEnabled = []; + private ?bool $canRecordSpans = null; + + private ?bool $canRecordBreadcrumbs = null; + /** * Create a new feature instance. */ @@ -69,11 +73,7 @@ public function onBootInactive(): void public function boot(): void { if ($this->isApplicable()) { - try { - $this->onBoot(); - } catch (Throwable) { - // If the feature setup fails, we don't want to prevent the rest of the SDK from working. - } + $this->onBoot(); } } @@ -83,11 +83,7 @@ public function boot(): void public function bootInactive(): void { if ($this->isApplicable()) { - try { - $this->onBootInactive(); - } catch (Throwable) { - // If the feature setup fails, we don't want to prevent the rest of the SDK from working. - } + $this->onBootInactive(); } } @@ -119,7 +115,8 @@ protected function shouldSendDefaultPii(): bool protected function isTracingFeatureEnabled(string $feature, bool $default = true): bool { if (! array_key_exists($feature, $this->isTracingFeatureEnabled)) { - $this->isTracingFeatureEnabled[$feature] = $this->isFeatureEnabled('tracing', $feature, $default); + $this->isTracingFeatureEnabled[$feature] = $this->canRecordSpans() + && $this->isFeatureEnabled('tracing', $feature, $default); } return $this->isTracingFeatureEnabled[$feature]; @@ -131,7 +128,8 @@ protected function isTracingFeatureEnabled(string $feature, bool $default = true protected function isBreadcrumbFeatureEnabled(string $feature, bool $default = true): bool { if (! array_key_exists($feature, $this->isBreadcrumbFeatureEnabled)) { - $this->isBreadcrumbFeatureEnabled[$feature] = $this->isFeatureEnabled('breadcrumbs', $feature, $default); + $this->isBreadcrumbFeatureEnabled[$feature] = $this->canRecordBreadcrumbs() + && $this->isFeatureEnabled('breadcrumbs', $feature, $default); } return $this->isBreadcrumbFeatureEnabled[$feature]; @@ -146,4 +144,32 @@ private function isFeatureEnabled(string $category, string $feature, bool $defau return ($config[$feature] ?? $default) === true; } + + /** + * Determine if the active SDK client can record spans. + */ + protected function canRecordSpans(): bool + { + if ($this->canRecordSpans !== null) { + return $this->canRecordSpans; + } + + return $this->canRecordSpans = $this->container + ->make(SdkCapabilities::class) + ->canRecordSpans(); + } + + /** + * Determine if the active SDK client can record breadcrumbs. + */ + protected function canRecordBreadcrumbs(): bool + { + if ($this->canRecordBreadcrumbs !== null) { + return $this->canRecordBreadcrumbs; + } + + return $this->canRecordBreadcrumbs = $this->container + ->make(SdkCapabilities::class) + ->canRecordBreadcrumbs(); + } } diff --git a/src/sentry/src/Features/Storage/CloudFilesystemDecorator.php b/src/sentry/src/Features/Storage/CloudFilesystemDecorator.php new file mode 100644 index 000000000..d902f51b8 --- /dev/null +++ b/src/sentry/src/Features/Storage/CloudFilesystemDecorator.php @@ -0,0 +1,18 @@ +withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } +} diff --git a/src/sentry/src/Features/Storage/DecoratedFilesystem.php b/src/sentry/src/Features/Storage/DecoratedFilesystem.php new file mode 100644 index 000000000..7b65380a4 --- /dev/null +++ b/src/sentry/src/Features/Storage/DecoratedFilesystem.php @@ -0,0 +1,18 @@ +getDescriptionAndDataForPathOrPaths($path); + + // Wrapped fluent assertions return the inner adapter; return this decorator + // instead so a following chained operation remains instrumented. + $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + + return $this; + } + + /** + * Assert that the given file or directory does not exist. + */ + public function assertMissing(array|string $path): static + { + [$description, $data] = $this->getDescriptionAndDataForPathOrPaths($path); + + $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + + return $this; + } + + /** + * Assert that the given directory is empty. + */ + public function assertDirectoryEmpty(string $path): static + { + $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + + return $this; + } + + /** + * Determine if a file exists. + */ + public function fileExists(string $path): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Determine if a directory exists. + */ + public function directoryExists(string $path): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get the checksum for a file. + */ + public function checksum(string $path, array $options = []): false|string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'options')); + } + + /** + * Get the mime-type of a given file. + */ + public function mimeType(string $path): false|string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Determine if temporary URLs can be generated. + */ + public function providesTemporaryUrls(): bool + { + return $this->wrappedAdapter()->providesTemporaryUrls(); + } + + /** + * Determine if temporary upload URLs can be generated. + */ + public function providesTemporaryUploadUrls(): bool + { + return $this->wrappedAdapter()->providesTemporaryUploadUrls(); + } + + /** + * Get a temporary URL for the file at the given path. + */ + public function temporaryUrl(string $path, DateTimeInterface $expiration, array $options = []): string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'expiration', 'options')); + } + + /** + * Get a temporary upload URL for the file at the given path. + */ + public function temporaryUploadUrl(string $path, DateTimeInterface $expiration, array $options = []): array|string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'expiration', 'options')); + } + + /** + * Define a custom temporary URL builder callback. + * + * Boot-only. The callback persists on the cached disk adapter for the + * worker lifetime and runs on every subsequent temporary URL generation for + * that disk. + */ + public function buildTemporaryUrlsUsing(?Closure $callback): void + { + $this->wrappedAdapter()->buildTemporaryUrlsUsing($callback); + } + + /** + * Define a custom temporary upload URL builder callback. + * + * Boot-only. The callback persists on the cached disk adapter for the + * worker lifetime and runs on every subsequent temporary upload URL + * generation for that disk. + */ + public function buildTemporaryUploadUrlsUsing(?Closure $callback): void + { + $this->wrappedAdapter()->buildTemporaryUploadUrlsUsing($callback); + } + + /** + * Get the wrapped filesystem adapter. + */ + private function wrappedAdapter(): FilesystemAdapter + { + /** @var FilesystemAdapter $filesystem */ + $filesystem = $this->filesystem; + + return $filesystem; + } +} diff --git a/src/sentry/src/Features/Storage/FilesystemDecorator.php b/src/sentry/src/Features/Storage/FilesystemDecorator.php new file mode 100644 index 000000000..ed9e683d6 --- /dev/null +++ b/src/sentry/src/Features/Storage/FilesystemDecorator.php @@ -0,0 +1,334 @@ + $args + * @param array $data + */ + protected function withSentry(string $method, array $args, ?string $description, array $data): mixed + { + $op = "file.{$method}"; // See https://develop.sentry.dev/sdk/performance/span-operations/#web-server + $data = array_merge($data, $this->defaultData); + + if ($this->recordBreadcrumbs) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + $op, + $description, + $data + )); + } + + if ($this->recordSpans) { + return trace( + function () use ($method, $args) { + return $this->filesystem->{$method}(...$args); + }, + SpanContext::make() + ->setOp($op) + ->setData($data) + ->setOrigin('auto.filesystem') + ->setDescription($description) + ); + } + + return $this->filesystem->{$method}(...$args); + } + + /** + * Get the full path to the file that exists at the given relative path. + */ + public function path(string $path): string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Determine if a file exists. + */ + public function exists(string $path): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get the contents of a file. + */ + public function get(string $path): ?string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get a resource to read the file. + * + * @return null|resource + */ + public function readStream(string $path): mixed + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get a resource to read part of the file. + * + * @return null|resource + */ + public function readStreamRange(string $path, ?int $start, ?int $end): mixed + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'start', 'end')); + } + + /** + * Write the contents of a file. + * + * @param File|resource|StreamInterface|string|UploadedFile $contents + */ + public function put(string $path, mixed $contents, mixed $options = []): bool|string + { + $description = is_string($contents) ? sprintf('%s (%s)', $path, Filesize::toHuman(strlen($contents))) : $path; + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path', 'options')); + } + + /** + * Store the uploaded file on the disk. + */ + public function putFile( + string|File|UploadedFile $path, + array|string|File|UploadedFile|null $file = null, + mixed $options = [] + ): false|string { + $description = is_string($path) ? $path : $path->getPathname(); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path', 'file', 'options')); + } + + /** + * Store the uploaded file on the disk with a given name. + */ + public function putFileAs( + string|File|UploadedFile $path, + array|string|File|UploadedFile|null $file, + array|string|null $name = null, + mixed $options = [] + ): false|string { + $description = is_string($path) ? $path : $path->getPathname(); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path', 'file', 'name', 'options')); + } + + /** + * Write a new file using a stream. + * + * @param resource $resource + */ + public function writeStream(string $path, mixed $resource, array $options = []): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'options')); + } + + /** + * Get the visibility for the given path. + */ + public function getVisibility(string $path): string + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Set the visibility for the given path. + */ + public function setVisibility(string $path, string $visibility): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'visibility')); + } + + /** + * Prepend to a file. + */ + public function prepend(string $path, string $data, string $separator = PHP_EOL): bool + { + $description = sprintf('%s (%s)', $path, Filesize::toHuman(strlen($data))); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path')); + } + + /** + * Append to a file. + */ + public function append(string $path, string $data, string $separator = PHP_EOL): bool + { + $description = sprintf('%s (%s)', $path, Filesize::toHuman(strlen($data))); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path')); + } + + /** + * Delete the file at a given path. + */ + public function delete(array|string $paths): bool + { + [$description, $data] = $this->getDescriptionAndDataForPathOrPaths($paths); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + } + + /** + * Copy a file to a new location. + */ + public function copy(string $from, string $to): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to')); + } + + /** + * Move a file to a new location. + */ + public function move(string $from, string $to): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to')); + } + + /** + * Get the file size of a given file. + */ + public function size(string $path): int + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get the file's last modification time. + */ + public function lastModified(string $path): int + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Get an array of all files in a directory. + */ + public function files(?string $directory = null, bool $recursive = false): array + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory', 'recursive')); + } + + /** + * Get all of the files from the given directory recursively. + */ + public function allFiles(?string $directory = null): array + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + /** + * Get all of the directories within a given directory. + */ + public function directories(?string $directory = null, bool $recursive = false): array + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory', 'recursive')); + } + + /** + * Get all of the directories recursively. + */ + public function allDirectories(?string $directory = null): array + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + /** + * Create a directory. + */ + public function makeDirectory(string $path): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + /** + * Recursively delete a directory. + */ + public function deleteDirectory(string $directory): bool + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + /** + * Get the wrapped filesystem. + */ + public function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + /** + * Invalidate the wrapped filesystem's pool when supported. + */ + public function invalidatePool(): bool + { + return $this->filesystem instanceof InvalidatesPool + && $this->filesystem->invalidatePool(); + } + + /** + * Get the description and data for one or more paths. + * + * @return array{0: string, 1: array} + */ + protected function getDescriptionAndDataForPathOrPaths(array|string $pathOrPaths): array + { + if (is_array($pathOrPaths)) { + $description = sprintf('%s paths', count($pathOrPaths)); + $data = ['paths' => $pathOrPaths]; + } else { + $description = $pathOrPaths; + $data = ['path' => $pathOrPaths]; + } + + return [$description, $data]; + } + + /** + * Dynamically proxy calls to the wrapped filesystem. + */ + public function __call(string $name, array $arguments): mixed + { + return $this->filesystem->{$name}(...$arguments); + } +} diff --git a/src/sentry/src/Features/Storage/Integration.php b/src/sentry/src/Features/Storage/Integration.php new file mode 100644 index 000000000..f0f5fb4d6 --- /dev/null +++ b/src/sentry/src/Features/Storage/Integration.php @@ -0,0 +1,148 @@ +container->afterResolving(FilesystemManager::class, function (FilesystemManager $filesystemManager): void { + // Store constants and default feature flags in local variables because `FilesystemManager::extend()` + // re-binds the closure scope to `FilesystemManager` which causes `self::` and `$this` to resolve + // on `FilesystemManager` instead of the `Integration` class. + $driverName = self::STORAGE_DRIVER_NAME; + $canRecordSpans = $this->canRecordSpans(); + $canRecordBreadcrumbs = $this->canRecordBreadcrumbs(); + $defaultRecordSpans = $this->isTracingFeatureEnabled(self::FEATURE_KEY); + $defaultRecordBreadcrumbs = $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + + $filesystemManager->extend( + $driverName, + function (Container $application, array $config, ?string $name) use ($filesystemManager, $driverName, $canRecordSpans, $canRecordBreadcrumbs, $defaultRecordSpans, $defaultRecordBreadcrumbs): Filesystem { + $disk = $name ?? ($config['sentry_disk_name'] ?? null); + + if (! is_string($disk) || $disk === '') { + throw new RuntimeException(sprintf('Missing `sentry_disk_name` config key for `%s` filesystem driver.', $driverName)); + } + + if (empty($config['sentry_original_driver'])) { + throw new RuntimeException(sprintf('Missing `sentry_original_driver` config key for `%s` filesystem driver.', $driverName)); + } + + if ($config['sentry_original_driver'] === $driverName) { + throw new RuntimeException(sprintf('`sentry_original_driver` for Sentry storage integration cannot be the `%s` driver.', $driverName)); + } + + $config['driver'] = $config['sentry_original_driver']; + unset($config['sentry_original_driver']); + + $originalFilesystem = $filesystemManager->build($config, $disk); + + if ($originalFilesystem instanceof DecoratedFilesystem) { + $originalFilesystem = $originalFilesystem->getFilesystem(); + } + + $defaultData = ['disk' => $disk, 'driver' => $config['driver']]; + + $recordSpans = $canRecordSpans + && ($config['sentry_enable_spans'] ?? $defaultRecordSpans) === true; + $recordBreadcrumbs = $canRecordBreadcrumbs + && ($config['sentry_enable_breadcrumbs'] ?? $defaultRecordBreadcrumbs) === true; + + if (! $recordSpans && ! $recordBreadcrumbs) { + return $originalFilesystem; + } + + if ($originalFilesystem instanceof AwsS3V3Adapter) { + return new SentryS3V3Adapter($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + if ($originalFilesystem instanceof FilesystemAdapter) { + return new SentryFilesystemAdapter($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + if ($originalFilesystem instanceof CloudFilesystem) { + return new SentryCloudFilesystem($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + return new SentryFilesystem($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + ); + }); + } + + /** + * Decorate the configuration for a single disk with Sentry driver configuration. + * + * This replaces the driver with a custom driver that will capture performance traces and breadcrumbs. + * + * The custom driver will be an instance of @see SentryS3V3Adapter if the original driver + * is an @see AwsS3V3Adapter, and an instance of @see SentryFilesystemAdapter if the original + * driver is an @see FilesystemAdapter. If the original driver is neither of those, it will + * be @see SentryFilesystem or @see SentryCloudFilesystem based on the original contract. + * + * You might run into problems if you expect another specific driver class. + * + * @param array $diskConfig + * + * @return array + */ + public static function configureDisk(string $diskName, array $diskConfig, bool $enableSpans = true, bool $enableBreadcrumbs = true): array + { + $currentDriver = $diskConfig['driver']; + + if ($currentDriver !== self::STORAGE_DRIVER_NAME) { + $diskConfig['driver'] = self::STORAGE_DRIVER_NAME; + $diskConfig['sentry_disk_name'] = $diskName; + $diskConfig['sentry_original_driver'] = $currentDriver; + $diskConfig['sentry_enable_spans'] = $enableSpans; + $diskConfig['sentry_enable_breadcrumbs'] = $enableBreadcrumbs; + } + + return $diskConfig; + } + + /** + * Decorate the configuration for all disks with Sentry driver configuration. + * + * @see self::configureDisk() + * + * @param array> $diskConfigs + * + * @return array> + */ + public static function configureDisks(array $diskConfigs, bool $enableSpans = true, bool $enableBreadcrumbs = true): array + { + $diskConfigsWithSentryDriver = []; + foreach ($diskConfigs as $diskName => $diskConfig) { + $diskConfigsWithSentryDriver[$diskName] = static::configureDisk($diskName, $diskConfig, $enableSpans, $enableBreadcrumbs); + } + + return $diskConfigsWithSentryDriver; + } +} diff --git a/src/sentry/src/Features/Storage/SentryCloudFilesystem.php b/src/sentry/src/Features/Storage/SentryCloudFilesystem.php new file mode 100644 index 000000000..839bcd97e --- /dev/null +++ b/src/sentry/src/Features/Storage/SentryCloudFilesystem.php @@ -0,0 +1,24 @@ +filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/src/sentry/src/Features/Storage/SentryFilesystem.php b/src/sentry/src/Features/Storage/SentryFilesystem.php new file mode 100644 index 000000000..386205ae5 --- /dev/null +++ b/src/sentry/src/Features/Storage/SentryFilesystem.php @@ -0,0 +1,24 @@ +filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php b/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php new file mode 100644 index 000000000..c37aeb409 --- /dev/null +++ b/src/sentry/src/Features/Storage/SentryFilesystemAdapter.php @@ -0,0 +1,32 @@ + $defaultData + */ + public function __construct( + FilesystemAdapter $filesystem, + array $defaultData, + bool $recordSpans, + bool $recordBreadcrumbs, + ) { + parent::__construct($filesystem->getDriver(), $filesystem->getAdapter(), $filesystem->getConfig()); + + $this->filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/src/sentry/src/Features/Storage/SentryS3V3Adapter.php b/src/sentry/src/Features/Storage/SentryS3V3Adapter.php new file mode 100644 index 000000000..78aa961f4 --- /dev/null +++ b/src/sentry/src/Features/Storage/SentryS3V3Adapter.php @@ -0,0 +1,32 @@ + $defaultData + */ + public function __construct( + AwsS3V3Adapter $filesystem, + array $defaultData, + bool $recordSpans, + bool $recordBreadcrumbs, + ) { + parent::__construct($filesystem->getDriver(), $filesystem->getAdapter(), $filesystem->getConfig(), $filesystem->getClient()); + + $this->filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/src/sentry/src/Http/FlushEventsMiddleware.php b/src/sentry/src/Http/FlushEventsMiddleware.php index 24e19b54e..69fb26c92 100644 --- a/src/sentry/src/Http/FlushEventsMiddleware.php +++ b/src/sentry/src/Http/FlushEventsMiddleware.php @@ -5,6 +5,7 @@ namespace Hypervel\Sentry\Http; use Closure; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Sentry\Integration; use Symfony\Component\HttpFoundation\Response; @@ -16,14 +17,10 @@ class FlushEventsMiddleware */ public function handle(Request $request, Closure $next): Response { - return $next($request); - } + Coroutine::defer(static function (): void { + Integration::flushEvents(); + }); - /** - * Perform cleanup after the response has been sent. - */ - public function terminate(Request $request, Response $response): void - { - Integration::flushEvents(); + return $next($request); } } diff --git a/src/sentry/src/SdkCapabilities.php b/src/sentry/src/SdkCapabilities.php new file mode 100644 index 000000000..d81056e1b --- /dev/null +++ b/src/sentry/src/SdkCapabilities.php @@ -0,0 +1,108 @@ +userConfig()); + } + + /** + * Determine if Spotlight is enabled. + */ + public function hasSpotlightEnabled(): bool + { + return self::configHasSpotlightEnabled($this->userConfig()); + } + + /** + * Determine if the SDK can record spans. + */ + public function canRecordSpans(): bool + { + $config = $this->userConfig(); + $enableTracing = $config['enable_tracing'] ?? null; + + // Mirror Options::__construct()'s legacy enable_tracing default and Options::isTracingEnabled(). + $tracingEnabled = $enableTracing === true + || ($enableTracing !== false + && (($config['traces_sample_rate'] ?? null) !== null + || ($config['traces_sampler'] ?? null) !== null)); + + return self::configHasActiveEndpoint($config) && $tracingEnabled; + } + + /** + * Determine if the SDK can record breadcrumbs. + */ + public function canRecordBreadcrumbs(): bool + { + $config = $this->userConfig(); + + return self::configHasActiveEndpoint($config) + && ($config['max_breadcrumbs'] ?? Options::DEFAULT_MAX_BREADCRUMBS) > 0; + } + + /** + * Retrieve the merged Sentry configuration. + * + * @return array + */ + private function userConfig(): array + { + return $this->config->array('sentry', []); + } + + /** + * Determine if the given configuration contains a DSN. + * + * @param array $config + */ + private static function configHasDsn(array $config): bool + { + return ! empty($config['dsn']); + } + + /** + * Determine if Spotlight is enabled in the given configuration. + * + * @param array $config + */ + private static function configHasSpotlightEnabled(array $config): bool + { + $spotlight = $config['spotlight'] ?? false; + + return $spotlight === true || (is_string($spotlight) && $spotlight !== ''); + } + + /** + * Determine if the given configuration has an active endpoint. + * + * @param array $config + */ + private static function configHasActiveEndpoint(array $config): bool + { + return self::configHasDsn($config) || self::configHasSpotlightEnabled($config); + } +} diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 689fbc84f..6fbb45d8a 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -38,6 +38,7 @@ use Hypervel\View\Engines\EngineResolver; use Hypervel\View\Factory as ViewFactory; use InvalidArgumentException; +use Psr\Log\LoggerInterface; use RuntimeException; use Sentry\ClientBuilder; use Sentry\Integration as SdkIntegration; @@ -46,6 +47,7 @@ use Sentry\SentrySdk; use Sentry\Serializer\RepresentationSerializer; use Sentry\State\HubInterface; +use Sentry\State\Layer; use Throwable; class SentryServiceProvider extends ServiceProvider @@ -63,8 +65,6 @@ class SentryServiceProvider extends ServiceProvider 'integrations', // We have this setting to allow us to capture the .env LOG_LEVEL for the sentry_logs channel 'logs_channel_level', - // Kept for backwards compatibility - 'breadcrumbs.sql_bindings', ]; /** @@ -95,10 +95,11 @@ public function boot(): void // 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->hasDsnSet() || $this->hasSpotlightEnabled()) { + if ($this->isActive()) { $this->bindEvents(); $this->registerMiddleware(); $this->bootTracing(); + $this->registerCoroutineContextPropagation(); } if ($this->app->runningInConsole()) { @@ -107,8 +108,6 @@ public function boot(): void } $this->registerAboutCommandIntegration(); - - $this->registerCoroutineContextPropagation(); } /** @@ -128,7 +127,9 @@ public function register(): void $this->registerLogChannels(); - $this->aspects(GuzzleHttpClientAspect::class); + if ($this->isActive()) { + $this->aspects(GuzzleHttpClientAspect::class); + } } /** @@ -169,8 +170,8 @@ protected function configureAndRegisterClient(): void $clientBuilder = ClientBuilder::create($options); - $clientBuilder->setSdkIdentifier(Version::SDK_IDENTIFIER); - $clientBuilder->setSdkVersion(Version::SDK_VERSION); + $clientBuilder->setSdkIdentifier(Version::getSdkIdentifier()); + $clientBuilder->setSdkVersion(Version::getSdkVersion()); // Set the pooled transport for async sending via Swoole coroutines $poolConfig = $this->app->make('config')->array('sentry.pool', []); @@ -199,7 +200,6 @@ protected function configureAndRegisterClient(): void $userIntegrations = $this->resolveIntegrationsFromUserConfig( is_array($userIntegrationOption) ? $userIntegrationOption : [], - $userConfig['tracing']['default_integrations'] ?? true ); $options->setIntegrations(static function (array $integrations) use ($options, $userIntegrations, $userIntegrationOption): array { @@ -220,7 +220,7 @@ protected function configureAndRegisterClient(): void } // Remove the default request integration so it can be re-added with - // a Hypervel-specific request fetcher that reads from coroutine context + // a Hypervel-specific request fetcher that reads from coroutine context. if ($integration instanceof SdkIntegration\RequestIntegration) { return false; } @@ -330,13 +330,13 @@ protected function registerMiddleware(): void $httpKernel = $this->app->make(HttpKernelInterface::class); - // Tracing middleware is prepended so it starts the transaction as early as possible - // in handle() and finishes the app span in terminate(). The transaction itself is - // finished later by a Coroutine::defer() to capture after-response work. + // The second prepend makes Flush outermost, so its defer runs after tracing and feature finalizers. $httpKernel->prependMiddleware(TracingMiddleware::class); + $httpKernel->prependMiddleware(FlushEventsMiddleware::class); - $httpKernel->pushMiddleware(SetRequestIpMiddleware::class); - $httpKernel->pushMiddleware(FlushEventsMiddleware::class); + if (SentrySdk::getCurrentHub()->getClient()?->getOptions()->shouldSendDefaultPii() === true) { + $httpKernel->pushMiddleware(SetRequestIpMiddleware::class); + } } /** @@ -344,16 +344,25 @@ protected function registerMiddleware(): void */ protected function bootTracing(): void { + $tracingConfig = $this->getUserConfig()['tracing'] ?? []; + // Register the tracing middleware as scoped so each coroutine gets its own instance. // Per-request state ($transaction, $appSpan, $didRouteMatch) is isolated between concurrent requests. - $this->app->scoped(TracingMiddleware::class); + $this->app->scoped( + TracingMiddleware::class, + static fn () => new TracingMiddleware( + ($tracingConfig['continue_after_response'] ?? true) === true, + ), + ); + + if (SentrySdk::getCurrentHub()->getClient()?->getOptions()->isTracingEnabled() !== true) { + return; + } $this->app->booted(function () { TracingMiddleware::setBootedTimestamp(); }); - $tracingConfig = $this->getUserConfig()['tracing'] ?? []; - $this->bindTracingEvents($tracingConfig); $this->bindViewEngine($tracingConfig); $this->decorateRoutingDispatchers(); @@ -450,21 +459,34 @@ private function decorateRoutingDispatchers(): void /** * Register the coroutine context propagation hook. * - * Copies the Sentry scope stack and HTTP request context from parent to child - * coroutines so that breadcrumbs, user context, and request data are available - * in child coroutines (e.g., async jobs, parallel queries). + * Copy isolated Sentry scope and request values into child coroutines. */ protected function registerCoroutineContextPropagation(): void { /* @phpstan-ignore-next-line */ - Coroutine::afterCreated(function () { + Coroutine::afterCreated(function (): void { $parentId = Coroutine::parentId(); + $stack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY) + ?? CoroutineContext::get(Hub::CONTEXT_STACK_KEY, null, $parentId); + + if ($stack !== null) { + CoroutineContext::set( + Hub::CONTEXT_STACK_KEY, + array_map( + static fn (Layer $layer): Layer => new Layer( + $layer->getClient(), + clone $layer->getScope(), + ), + $stack, + ), + ); + } - foreach ([Hub::CONTEXT_STACK_KEY, Request::class] as $key) { - $value = CoroutineContext::get($key, null, $parentId); - if ($value !== null) { - CoroutineContext::set($key, $value); - } + $request = CoroutineContext::get(Request::class) + ?? CoroutineContext::get(Request::class, null, $parentId); + + if ($request !== null) { + CoroutineContext::set(Request::class, clone $request); } }); } @@ -476,18 +498,14 @@ protected function registerFeatures(): void { $features = $this->app->make('config')->array('sentry.features', []); - foreach ($features as $feature) { - $this->app->singleton($feature); - } - foreach ($features as $feature) { try { /** @var Feature $featureInstance */ $featureInstance = $this->app->make($feature); $featureInstance->register(); - } catch (Throwable) { - // Ensure that features do not break the whole application + } catch (Throwable $exception) { + $this->reportFeatureFailure($feature, 'register', $exception); } } } @@ -497,7 +515,7 @@ protected function registerFeatures(): void */ protected function bootFeatures(): void { - $bootActive = $this->hasDsnSet() || $this->hasSpotlightEnabled(); + $bootActive = $this->isActive(); $features = $this->app->make('config')->array('sentry.features', []); @@ -509,12 +527,31 @@ protected function bootFeatures(): void $bootActive ? $featureInstance->boot() : $featureInstance->bootInactive(); - } catch (Throwable) { - // Ensure that features do not break the whole application + } catch (Throwable $exception) { + $this->reportFeatureFailure( + $feature, + $bootActive ? 'boot' : 'bootInactive', + $exception, + ); } } } + /** + * Report a feature phase that did not complete. + */ + private function reportFeatureFailure(string $feature, string $phase, Throwable $exception): void + { + $this->app->make(LoggerInterface::class)->warning( + "Sentry feature [{$feature}] failed during [{$phase}]. The phase did not complete, any effects applied before the failure remain in place, and the phase will not be retried for this worker lifetime.", + [ + 'feature' => $feature, + 'phase' => $phase, + 'exception' => $exception, + ], + ); + } + /** * Register the sentry and sentry_logs log channels. */ @@ -572,7 +609,7 @@ protected function registerAboutCommandIntegration(): void * * @return SdkIntegration\IntegrationInterface[] */ - private function resolveIntegrationsFromUserConfig(array $userIntegrations, bool $enableDefaultTracingIntegrations): array + private function resolveIntegrationsFromUserConfig(array $userIntegrations): array { $integrationsToResolve = $userIntegrations; @@ -611,14 +648,20 @@ private function resolveIntegrationsFromUserConfig(array $userIntegrations, bool return $integrations; } + /** + * Determine if Sentry has an active endpoint. + */ + protected function isActive(): bool + { + return $this->hasDsnSet() || $this->hasSpotlightEnabled(); + } + /** * Check if a DSN was set in the config. */ protected function hasDsnSet(): bool { - $config = $this->getUserConfig(); - - return ! empty($config['dsn']); + return $this->app->make(SdkCapabilities::class)->hasDsnSet(); } /** @@ -626,9 +669,7 @@ protected function hasDsnSet(): bool */ protected function hasSpotlightEnabled(): bool { - $config = $this->getUserConfig(); - - return ($config['spotlight'] ?? false) === true; + return $this->app->make(SdkCapabilities::class)->hasSpotlightEnabled(); } /** diff --git a/src/sentry/src/Tracing/Middleware.php b/src/sentry/src/Tracing/Middleware.php index ec84f325c..31d4b0c11 100644 --- a/src/sentry/src/Tracing/Middleware.php +++ b/src/sentry/src/Tracing/Middleware.php @@ -7,7 +7,6 @@ use Closure; use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; -use Hypervel\Sentry\Integration; use Sentry\SentrySdk; use Sentry\State\HubInterface; use Sentry\Tracing\Span; @@ -47,6 +46,14 @@ class Middleware */ private bool $didRouteMatch = false; + /** + * Create a new tracing middleware instance. + */ + public function __construct( + private readonly bool $continueAfterResponse = true, + ) { + } + /** * Handle an incoming request. */ @@ -62,10 +69,8 @@ public function handle(Request $request, Closure $next): Response /** * Perform cleanup after the response has been sent. * - * The transaction is not finished here — it stays open to capture spans - * created by after-response work (e.g. dispatchAfterResponse). A - * Coroutine::defer() registered in startTransaction() finishes the - * transaction when the coroutine exits, after all deferred work completes. + * When after-response tracing is enabled, a Coroutine::defer() registered + * in startTransaction() finishes the transaction after deferred work. */ public function terminate(Request $request, Response $response): void { @@ -85,6 +90,10 @@ public function terminate(Request $request, Response $response): void } $this->hydrateResponseData($response); + + if (! $this->continueAfterResponse) { + $this->finishTransaction(); + } } /** @@ -135,6 +144,7 @@ public function finishTransaction(): void private function startTransaction(Request $request): void { $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); // Prevent starting a new transaction if we are already in a transaction if ($hub->getTransaction() !== null) { @@ -146,6 +156,10 @@ private function startTransaction(Request $request): void $request->header('baggage', '') ); + if ($client === null || ! $client->getOptions()->isTracingEnabled()) { + return; + } + $requestPath = '/' . ltrim($request->path(), '/'); $context->setOp('http.server'); @@ -172,14 +186,13 @@ private function startTransaction(Request $request): void $this->transaction = $transaction; - // Register a coroutine defer to finish the transaction when the coroutine exits. - // Since defers run in LIFO order, this early registration ensures the transaction - // finishes LAST — after all dispatchAfterResponse() work and other deferred callbacks - // have completed, capturing their spans on the transaction. - Coroutine::defer(function () { - $this->finishTransaction(); - Integration::flushEvents(); - }); + if ($this->continueAfterResponse) { + // This runs before the earlier outer flush defer and after later + // after-response work because coroutine defers are LIFO. + Coroutine::defer(function (): void { + $this->finishTransaction(); + }); + } $bootstrapSpan = $this->addAppBootstrapSpan(); diff --git a/src/sentry/src/Version.php b/src/sentry/src/Version.php index 70cdd097c..38a873986 100644 --- a/src/sentry/src/Version.php +++ b/src/sentry/src/Version.php @@ -5,13 +5,14 @@ namespace Hypervel\Sentry; use Hypervel\Container\Container; +use Hypervel\Foundation\Application; use Hypervel\Foundation\PackageManifest; final class Version { public const SDK_IDENTIFIER = 'sentry.php.hypervel'; - public const SDK_VERSION = '4.21.1'; + public const SDK_VERSION = Application::VERSION; public static function getSdkIdentifier(): string { diff --git a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php index bd8579da2..5629def9f 100644 --- a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php +++ b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php @@ -13,6 +13,8 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithAop; use Hypervel\Tests\Sentry\SentryTestCase; use Psr\Http\Message\RequestInterface; +use RuntimeException; +use Sentry\SentrySdk; use Sentry\Tracing\SpanStatus; class GuzzleHttpClientAspectTest extends SentryTestCase @@ -112,6 +114,44 @@ public function testSpanIsRecordedWithCorrectStatus() $this->assertEquals(SpanStatus::internalError(), $span->getStatus()); } + public function testHttpSpanNeverBecomesTheHubCurrentSpan(): void + { + $transaction = $this->startTransaction(); + $observedSpan = null; + $client = $this->makeClient([ + static function () use (&$observedSpan): Response { + $observedSpan = SentrySdk::getCurrentHub()->getSpan(); + + return new Response(200, [], 'OK'); + }, + ]); + + $this->executeTransfer($client, new Request('GET', 'https://example.com')); + + $this->assertSame($transaction, $observedSpan); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + } + + public function testFailedTransferFinishesTheExactHttpSpan(): void + { + $transaction = $this->startTransaction(); + $exception = new RuntimeException('Connection failed.'); + $client = $this->makeClient([$exception]); + + try { + $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $this->fail('Expected the transfer to fail.'); + } catch (RuntimeException $thrown) { + $this->assertSame($exception, $thrown); + } + + $span = last($transaction->getSpanRecorder()->getSpans()); + $this->assertSame('http.client', $span->getOp()); + $this->assertNotNull($span->getEndTimestamp()); + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + } + public function testSpanIsNotRecordedWhenDisabled() { $this->resetApplicationWithConfig([ @@ -156,6 +196,63 @@ public function testTracingHeadersAreAttached() $this->assertFalse($sentRequest->hasHeader('baggage')); } + public function testTracingHeadersAreAttachedWhenLocalRecordingIsDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.http_client_requests' => false, + 'sentry.breadcrumbs.http_client_requests' => false, + ]); + $mock = new MockHandler([new Response(200, [], 'OK')]); + $client = new Client(['handler' => HandlerStack::create($mock)]); + + $this->executeTransfer($client, new Request('GET', 'https://example.com')); + + $sentRequest = $mock->getLastRequest(); + $this->assertTrue($sentRequest->hasHeader('sentry-trace')); + $this->assertTrue($sentRequest->hasHeader('baggage')); + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } + + public function testTransferStatsCallbackIsNotWrappedWhenLocalOutputIsDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.http_client_requests' => false, + 'sentry.breadcrumbs.http_client_requests' => false, + ]); + $observedOnStats = null; + $existingOnStats = static function (TransferStats $stats): void { + }; + $client = $this->makeClient([ + static function (RequestInterface $request, array $options) use (&$observedOnStats): Response { + $observedOnStats = $options['on_stats'] ?? null; + + return new Response(200, [], 'OK'); + }, + ]); + + $this->executeTransfer($client, new Request('GET', 'https://example.com'), [ + 'on_stats' => $existingOnStats, + ]); + + $this->assertSame($existingOnStats, $observedOnStats); + } + + public function testLegacyEnableTracingOptionRecordsSpansWithoutAnExplicitSampler(): void + { + $this->resetApplicationWithConfig([ + 'sentry.enable_tracing' => true, + 'sentry.traces_sample_rate' => null, + 'sentry.breadcrumbs.http_client_requests' => false, + ]); + $transaction = $this->startTransaction(); + $client = $this->makeClient([new Response(200, [], 'OK')]); + + $this->executeTransfer($client, new Request('GET', 'https://example.com')); + + $span = last($transaction->getSpanRecorder()->getSpans()); + $this->assertSame('http.client', $span->getOp()); + } + public function testPerRequestOptOut() { $client = $this->makeClient([ diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php new file mode 100644 index 000000000..8c3a19c2f --- /dev/null +++ b/tests/Sentry/Features/StorageIntegrationTest.php @@ -0,0 +1,572 @@ + 1.0, + ]; + + public function testCreatesSpansFor(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $transaction = $this->startTransaction(); + + Storage::put('foo', 'bar'); + $fooContent = Storage::get('foo'); + Storage::assertExists('foo', 'bar'); + Storage::delete('foo'); + Storage::delete(['foo', 'bar']); + Storage::files(); + Storage::assertMissing(['foo', 'bar']); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertArrayHasKey(1, $spans); + $span = $spans[1]; + $this->assertSame('file.put', $span->getOp()); + $this->assertSame('foo (3 B)', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'options' => [], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(2, $spans); + $span = $spans[2]; + $this->assertSame('file.get', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + $this->assertSame('bar', $fooContent); + + $this->assertArrayHasKey(3, $spans); + $span = $spans[3]; + $this->assertSame('file.assertExists', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(4, $spans); + $span = $spans[4]; + $this->assertSame('file.delete', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(5, $spans); + $span = $spans[5]; + $this->assertSame('file.delete', $span->getOp()); + $this->assertSame('2 paths', $span->getDescription()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(6, $spans); + $span = $spans[6]; + $this->assertSame('file.files', $span->getOp()); + $this->assertNull($span->getDescription()); + $this->assertSame(['directory' => null, 'recursive' => false, 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(7, $spans); + $span = $spans[7]; + $this->assertSame('file.assertMissing', $span->getOp()); + $this->assertSame('2 paths', $span->getDescription()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + } + + public function testDoesntCreateSpansWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks'), false), + ]); + + $transaction = $this->startTransaction(); + + Storage::exists('foo'); + + $this->assertCount(1, $transaction->getSpanRecorder()->getSpans()); + } + + public function testAdapterSpecificOperationsAndFluentAssertionsRemainInstrumented(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $disk = Storage::disk('local'); + $disk->put('foo.txt', 'bar'); + $disk->makeDirectory('empty'); + $transaction = $this->startTransaction(); + + $result = $disk + ->assertExists('foo.txt') + ->assertMissing('missing') + ->assertDirectoryEmpty('empty'); + + $this->assertSame($disk, $result); + $this->assertTrue($result->exists('foo.txt')); + $this->assertTrue($disk->fileExists('foo.txt')); + $this->assertTrue($disk->directoryExists('empty')); + $this->assertIsString($disk->checksum('foo.txt')); + $this->assertIsString($disk->mimeType('foo.txt')); + + $operations = array_map( + static fn ($span): ?string => $span->getOp(), + $transaction->getSpanRecorder()->getSpans(), + ); + + $this->assertSame([ + null, + 'file.assertExists', + 'file.assertMissing', + 'file.assertDirectoryEmpty', + 'file.exists', + 'file.fileExists', + 'file.directoryExists', + 'file.checksum', + 'file.mimeType', + ], $operations); + + $disk->delete(['foo.txt']); + $disk->deleteDirectory('empty'); + } + + public function testTemporaryUrlCapabilitiesAndCallbacksDelegateToWrappedAdapter(): void + { + $disks = config('filesystems.disks'); + $disks['local']['serve'] = true; + $disks['plain'] = [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/plain'), + ]; + + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks($disks), + ]); + + $served = Storage::disk('local'); + $plain = Storage::disk('plain'); + + $this->assertTrue($served->providesTemporaryUrls()); + $this->assertTrue($served->providesTemporaryUploadUrls()); + $this->assertFalse($plain->providesTemporaryUrls()); + $this->assertFalse($plain->providesTemporaryUploadUrls()); + + $expiration = new DateTimeImmutable('+5 minutes'); + $plain->buildTemporaryUrlsUsing( + static fn (string $path): string => "https://files.test/{$path}", + ); + $plain->buildTemporaryUploadUrlsUsing( + static fn (string $path): array => ['url' => "https://uploads.test/{$path}", 'headers' => ['X-Test' => 'true']], + ); + + $this->assertTrue($plain->providesTemporaryUrls()); + $this->assertTrue($plain->providesTemporaryUploadUrls()); + $this->assertSame('https://files.test/foo.txt', $plain->temporaryUrl('foo.txt', $expiration)); + $this->assertSame( + ['url' => 'https://uploads.test/foo.txt', 'headers' => ['X-Test' => 'true']], + $plain->temporaryUploadUrl('foo.txt', $expiration), + ); + } + + #[DataProvider('adapterDecoratorPairs')] + public function testEveryConcreteAdapterMethodHasExplicitDecoratorOwnership( + string $baseClass, + string $decoratorClass, + array $expectedInherited, + ): void { + $base = new ReflectionClass($baseClass); + $decorator = new ReflectionClass($decoratorClass); + $inherited = []; + + foreach ($base->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic()) { + continue; + } + + if ($decorator->getMethod($method->getName())->getDeclaringClass()->getName() === $baseClass) { + $inherited[] = $method->getName(); + } + } + + sort($inherited); + + // These methods either compose instrumented methods on the outer adapter, + // own outer response/config state, or provide generic fluent/macro behavior. + $this->assertSame($expectedInherited, $inherited); + } + + /** + * Provide every concrete adapter and Sentry decorator pair. + * + * @return array}> + */ + public static function adapterDecoratorPairs(): array + { + return [ + 'filesystem adapter' => [ + FilesystemAdapter::class, + SentryFilesystemAdapter::class, + [ + 'assertCount', + 'assertEmpty', + 'directoryMissing', + 'download', + 'fileMissing', + 'getAdapter', + 'getConfig', + 'getDriver', + 'json', + 'macroCall', + 'missing', + 'response', + 'serve', + 'serveUsing', + 'unless', + 'when', + ], + ], + 'S3 adapter' => [ + AwsS3V3Adapter::class, + SentryS3V3Adapter::class, + [ + 'getClient', + 'unless', + 'when', + ], + ], + ]; + } + + public function testTransformedServedLocalDiskKeepsItsRouteAndTelemetry(): void + { + $disks = config('filesystems.disks'); + $disks['local']['serve'] = true; + $disks['local']['url'] = '/sentry-storage'; + + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks($disks), + ]); + + Storage::put('served.txt', 'served through Sentry'); + $url = Storage::temporaryUrl('served.txt', new DateTimeImmutable('+5 minutes')); + $transaction = $this->startTransaction(); + + $this->assertNotNull(Route::getRoutes()->getByName('storage.local')); + $this->assertStringContainsString('/sentry-storage/served.txt', $url); + + $response = $this->get($url); + + $response->assertOk(); + $this->assertSame('served through Sentry', $response->streamedContent()); + $this->assertContains( + 'file.mimeType', + array_map( + static fn ($span): ?string => $span->getOp(), + $transaction->getSpanRecorder()->getSpans(), + ), + ); + + Storage::delete('served.txt'); + } + + public function testCloudAndRangeOperationsRemainInstrumented(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $disk = Storage::disk('local'); + $disk->put('range.txt', 'abcdef'); + $transaction = $this->startTransaction(); + + $this->assertStringEndsWith('/range.txt', $disk->url('range.txt')); + $stream = $disk->readStreamRange('range.txt', 1, 3); + $this->assertIsResource($stream); + $this->assertSame('bcdef', stream_get_contents($stream)); + fclose($stream); + + $operations = array_map( + static fn ($span): ?string => $span->getOp(), + $transaction->getSpanRecorder()->getSpans(), + ); + + $this->assertSame([null, 'file.url', 'file.readStreamRange'], $operations); + + $disk->delete('range.txt'); + } + + public function testNestedScopedDisksUseOneOuterDecoratorAndLogicalName(): void + { + $disks = [ + 'parent' => [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/scoped-parent'), + ], + 'tenant' => [ + 'driver' => 'scoped', + 'disk' => 'parent', + 'prefix' => 'tenant-prefix', + ], + ]; + + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks($disks), + ]); + + $disk = Storage::disk('tenant'); + $this->assertInstanceOf(DecoratedFilesystem::class, $disk); + $this->assertNotInstanceOf(DecoratedFilesystem::class, $disk->getFilesystem()); + $this->assertFalse($disk->invalidatePool()); + + $disk->put('foo.txt', 'tenant data'); + $transaction = $this->startTransaction(); + $this->assertTrue($disk->exists('foo.txt')); + $this->assertTrue(Storage::disk('parent')->exists('tenant-prefix/foo.txt')); + + $spans = $transaction->getSpanRecorder()->getSpans(); + $this->assertSame('tenant', $spans[1]->getData()['disk']); + $this->assertSame('scoped', $spans[1]->getData()['driver']); + + $disk->delete('foo.txt'); + } + + public function testForgottenTransformedDiskForwardsPoolInvalidation(): void + { + $disks = Integration::configureDisks([ + 'pooled' => [ + 'driver' => 'pooled-local', + 'root' => storage_path('framework/testing/disks/pooled'), + ], + ]); + + $this->resetApplicationWithConfig(['filesystems.disks' => $disks]); + + $manager = $this->app->make(FilesystemManager::class); + $manager->extend('pooled-local', static function (Container $app, array $config): FilesystemAdapter { + $adapter = new LocalFilesystemAdapter($config['root']); + + return new FilesystemAdapter(new Flysystem($adapter), $adapter, $config); + }, poolable: true); + + $disk = $manager->disk('pooled'); + $this->assertInstanceOf(DecoratedFilesystem::class, $disk); + $pooled = $disk->getFilesystem(); + $this->assertInstanceOf(FilesystemPoolProxy::class, $pooled); + $this->assertFalse($disk->exists('missing.txt')); + + $pools = $this->app->make(PoolFactory::class); + $this->assertTrue($pools->has($pooled->getPoolName())); + + $manager->forgetDisk('pooled'); + $manager->purge('pooled'); + + $this->assertFalse($pools->has($pooled->getPoolName())); + } + + public function testCreatesBreadcrumbsFor(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + Storage::put('foo', 'bar'); + $fooContent = Storage::get('foo'); + Storage::assertExists('foo', 'bar'); + Storage::delete('foo'); + Storage::delete(['foo', 'bar']); + Storage::files(); + + $breadcrumbs = $this->getCurrentSentryBreadcrumbs(); + + $this->assertArrayHasKey(0, $breadcrumbs); + $span = $breadcrumbs[0]; + $this->assertSame('file.put', $span->getCategory()); + $this->assertSame('foo (3 B)', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'options' => [], 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(1, $breadcrumbs); + $span = $breadcrumbs[1]; + $this->assertSame('file.get', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + $this->assertSame('bar', $fooContent); + + $this->assertArrayHasKey(2, $breadcrumbs); + $span = $breadcrumbs[2]; + $this->assertSame('file.assertExists', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(3, $breadcrumbs); + $span = $breadcrumbs[3]; + $this->assertSame('file.delete', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(4, $breadcrumbs); + $span = $breadcrumbs[4]; + $this->assertSame('file.delete', $span->getCategory()); + $this->assertSame('2 paths', $span->getMessage()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(5, $breadcrumbs); + $span = $breadcrumbs[5]; + $this->assertSame('file.files', $span->getCategory()); + $this->assertNull($span->getMessage()); + $this->assertSame(['directory' => null, 'recursive' => false, 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + } + + public function testDoesntCreateBreadcrumbsWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks'), true, false), + ]); + + Storage::exists('foo'); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + public function testDriverWorksWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.dsn' => null, + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $disk = Storage::disk('local'); + + $this->assertNotInstanceOf(DecoratedFilesystem::class, $disk); + $this->assertFalse($disk->exists('foo')); + } + + public function testReturnsOriginalFilesystemWhenBothOutputsAreDisabled(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks'), false, false), + ]); + + $disk = Storage::disk('local'); + + $this->assertNotInstanceOf(DecoratedFilesystem::class, $disk); + $this->assertFalse($disk->exists('foo')); + } + + public function testResolvingDiskDoesNotModifyConfig(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $originalConfig = config('filesystems.disks.local'); + + Storage::disk('local'); + + $this->assertEquals($originalConfig, config('filesystems.disks.local')); + } + + public function testCreatesSpansWithoutExplicitConfigOption(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local' => [ + 'driver' => 'sentry', + 'sentry_disk_name' => 'local', + 'sentry_original_driver' => 'local', + 'root' => storage_path('framework/testing/disks/local'), + ], + ]); + + $transaction = $this->startTransaction(); + + Storage::exists('foo'); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame('file.exists', $spans[1]->getOp()); + } + + public function testCreatesBreadcrumbsWithoutExplicitConfigOption(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local' => [ + 'driver' => 'sentry', + 'sentry_disk_name' => 'local', + 'sentry_original_driver' => 'local', + 'root' => storage_path('framework/testing/disks/local'), + ], + ]); + + Storage::exists('foo'); + + $breadcrumbs = $this->getCurrentSentryBreadcrumbs(); + + $this->assertCount(1, $breadcrumbs); + $this->assertSame('file.exists', $breadcrumbs[0]->getCategory()); + } + + public function testNamedDiskUsesResolvedLogicalNameWithoutStoredConfig(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_original_driver' => 'local', + ]); + + $this->assertFalse(Storage::disk('local')->exists('missing')); + } + + public function testAnonymousDiskRequiresStoredLogicalName(): void + { + $this->expectExceptionMessage('Missing `sentry_disk_name` config key for `sentry` filesystem driver.'); + + Storage::build([ + 'driver' => 'sentry', + 'sentry_original_driver' => 'local', + 'root' => storage_path('framework/testing/disks/anonymous'), + ]); + } + + public function testThrowsIfDiskConfigurationDoesntSpecifyOriginalDriver(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_disk_name' => 'local', + ]); + + $this->expectExceptionMessage('Missing `sentry_original_driver` config key for `sentry` filesystem driver.'); + + Storage::disk('local'); + } + + public function testThrowsIfDiskConfigurationCreatesCircularReference(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_disk_name' => 'local', + 'filesystems.disks.local.sentry_original_driver' => 'sentry', + ]); + + $this->expectExceptionMessage('`sentry_original_driver` for Sentry storage integration cannot be the `sentry` driver.'); + + Storage::disk('local'); + } +} diff --git a/tests/Sentry/Http/FlushEventsMiddlewareTest.php b/tests/Sentry/Http/FlushEventsMiddlewareTest.php new file mode 100644 index 000000000..7af4ca43f --- /dev/null +++ b/tests/Sentry/Http/FlushEventsMiddlewareTest.php @@ -0,0 +1,55 @@ +shouldReceive('flush') + ->once() + ->with(null) + ->andReturnUsing(static function () use ($flushed): Result { + $flushed->push(true); + + return new Result(ResultStatus::success()); + }); + $previousHub = SentrySdk::getCurrentHub(); + SentrySdk::setCurrentHub(new Hub($client)); + + try { + Coroutine::create(static function () use ($handled): void { + $response = (new FlushEventsMiddleware)->handle( + Request::create('/'), + static fn (): Response => new Response('OK'), + ); + + $handled->push($response->getContent()); + }); + + $this->assertSame('OK', $handled->pop(1.0)); + $this->assertTrue($flushed->pop(1.0)); + $this->assertFalse(method_exists(FlushEventsMiddleware::class, 'terminate')); + } finally { + SentrySdk::setCurrentHub($previousHub); + } + } +} diff --git a/tests/Sentry/SentryTestCase.php b/tests/Sentry/SentryTestCase.php index 81d1d2453..4c85eab08 100644 --- a/tests/Sentry/SentryTestCase.php +++ b/tests/Sentry/SentryTestCase.php @@ -48,18 +48,6 @@ protected function defineEnvironment(ApplicationContract $app): void return null; }); - - if ($config->get('sentry_test.override_dsn') !== true) { - $config->set('sentry.dsn', 'https://publickey@sentry.dev/123'); - } - - foreach ($this->defaultSetupConfig as $key => $value) { - $config->set($key, $value); - } - - foreach ($this->setupConfig as $key => $value) { - $config->set($key, $value); - } }); $app->extend(ExceptionHandler::class, function (ExceptionHandler $handler) { @@ -80,6 +68,20 @@ protected function envSamplingAllTransactions(ApplicationContract $app): void protected function getPackageProviders(ApplicationContract $app): array { + $config = $app->make('config'); + + if ($config->get('sentry_test.override_dsn') !== true) { + $config->set('sentry.dsn', 'https://publickey@sentry.dev/123'); + } + + foreach ($this->defaultSetupConfig as $key => $value) { + $config->set($key, $value); + } + + foreach ($this->setupConfig as $key => $value) { + $config->set($key, $value); + } + return [ SentryServiceProvider::class, ]; diff --git a/tests/Sentry/ServiceProviderListenerRegistrationTest.php b/tests/Sentry/ServiceProviderListenerRegistrationTest.php index f4d505f32..2b67c7ad3 100644 --- a/tests/Sentry/ServiceProviderListenerRegistrationTest.php +++ b/tests/Sentry/ServiceProviderListenerRegistrationTest.php @@ -11,6 +11,10 @@ class ServiceProviderListenerRegistrationTest extends SentryTestCase { + protected array $defaultSetupConfig = [ + 'sentry.traces_sample_rate' => 1.0, + ]; + public function testQueryExecutedIsNotRegisteredWhenSqlBreadcrumbsAndTracingAreDisabled(): void { $this->resetApplicationWithConfig([ diff --git a/tests/Sentry/ServiceProviderTest.php b/tests/Sentry/ServiceProviderTest.php index ddfee7f25..06a8e658e 100644 --- a/tests/Sentry/ServiceProviderTest.php +++ b/tests/Sentry/ServiceProviderTest.php @@ -5,14 +5,21 @@ namespace Hypervel\Tests\Sentry; use Hypervel\Contracts\Http\Kernel; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Http\Request; +use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect; use Hypervel\Sentry\Facade; +use Hypervel\Sentry\Features\Feature; use Hypervel\Sentry\Http\FlushEventsMiddleware; use Hypervel\Sentry\Http\SetRequestIpMiddleware; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Sentry\Tracing\Middleware as TracingMiddleware; use Hypervel\Support\Facades\Artisan; use Mockery as m; +use Psr\Log\LoggerInterface; +use RuntimeException; use Sentry\State\HubInterface; +use Symfony\Component\HttpFoundation\Response; class ServiceProviderTest extends SentryTestCase { @@ -61,20 +68,135 @@ public function testMiddlewareRegistersThroughTheKernelContract(): void $kernel->shouldReceive('prependMiddleware') ->once() ->with(TracingMiddleware::class) + ->ordered() ->andReturnSelf(); - $kernel->shouldReceive('pushMiddleware') + $kernel->shouldReceive('prependMiddleware') ->once() - ->with(SetRequestIpMiddleware::class) + ->with(FlushEventsMiddleware::class) + ->ordered() ->andReturnSelf(); - $kernel->shouldReceive('pushMiddleware') + $kernel->shouldNotReceive('pushMiddleware'); + $this->app->instance(Kernel::class, $kernel); + + (new InspectableSentryServiceProvider($this->app)) + ->registerMiddlewareForTest(); + } + + public function testRequestIpMiddlewareIsRegisteredWhenPiiIsEnabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.send_default_pii' => true, + ]); + $kernel = m::mock(Kernel::class); + $kernel->shouldReceive('prependMiddleware') + ->once() + ->with(TracingMiddleware::class) + ->ordered() + ->andReturnSelf(); + $kernel->shouldReceive('prependMiddleware') ->once() ->with(FlushEventsMiddleware::class) + ->ordered() + ->andReturnSelf(); + $kernel->shouldReceive('pushMiddleware') + ->once() + ->with(SetRequestIpMiddleware::class) + ->ordered() ->andReturnSelf(); $this->app->instance(Kernel::class, $kernel); (new InspectableSentryServiceProvider($this->app)) ->registerMiddlewareForTest(); } + + public function testLegacyAndZeroRateTracingOptionsKeepFeatureSpansEnabled(): void + { + config()->set('sentry.enable_tracing', true); + config()->set('sentry.traces_sample_rate', null); + config()->set('sentry.traces_sampler', null); + + $this->assertTrue((new InspectableSentryFeature($this->app))->canRecordSpansForTest()); + + config()->set('sentry.enable_tracing', null); + config()->set('sentry.traces_sample_rate', 0.0); + + $this->assertTrue((new InspectableSentryFeature($this->app))->canRecordSpansForTest()); + } + + public function testFeatureCapabilitiesRequireAnActiveEndpointAndUsableBreadcrumbLimit(): void + { + config()->set('sentry.dsn', null); + config()->set('sentry.spotlight', false); + config()->set('sentry.traces_sample_rate', 1.0); + + $inactive = new InspectableSentryFeature($this->app); + $this->assertFalse($inactive->canRecordSpansForTest()); + $this->assertFalse($inactive->canRecordBreadcrumbsForTest()); + + config()->set('sentry.spotlight', 'http://localhost:8969/stream'); + config()->set('sentry.max_breadcrumbs', 0); + + $active = new InspectableSentryFeature($this->app); + $this->assertTrue($active->canRecordSpansForTest()); + $this->assertFalse($active->canRecordBreadcrumbsForTest()); + } + + public function testSpotlightUrlRegistersTheGuzzleAspect(): void + { + $this->resetApplicationWithConfig([ + 'sentry.dsn' => null, + 'sentry.spotlight' => 'http://localhost:8969/stream', + 'sentry_test.override_dsn' => true, + ]); + + $this->assertNotEmpty(AspectCollector::getRule(GuzzleHttpClientAspect::class)); + } + + public function testFeatureFailureIsLoggedWithoutOverwritingItsInstanceOrSkippingBoot(): void + { + $exception = new RuntimeException('Feature registration failed.'); + $feature = new FailingRegistrationSentryFeature($this->app); + $feature->exception = $exception; + $logger = m::mock(LoggerInterface::class); + $logger->shouldReceive('warning') + ->once() + ->withArgs(static function (string $message, array $context) use ($exception): bool { + return str_contains($message, 'failed during [register]') + && str_contains($message, 'effects applied before the failure remain in place') + && str_contains($message, 'will not be retried for this worker lifetime') + && $context === [ + 'feature' => FailingRegistrationSentryFeature::class, + 'phase' => 'register', + 'exception' => $exception, + ]; + }); + $this->app->instance('log', $logger); + $this->app->instance(FailingRegistrationSentryFeature::class, $feature); + config()->set('sentry.features', [FailingRegistrationSentryFeature::class]); + $provider = new InspectableSentryServiceProvider($this->app); + + $provider->registerFeaturesForTest(); + $provider->bootFeaturesForTest(); + + $this->assertSame($feature, $this->app->make(FailingRegistrationSentryFeature::class)); + $this->assertTrue($feature->booted); + } + + public function testTracingMiddlewareHonorsDisabledAfterResponseContinuation(): void + { + $this->resetApplicationWithConfig([ + 'sentry.traces_sample_rate' => 1.0, + 'sentry.tracing.continue_after_response' => false, + 'sentry.tracing.missing_routes' => true, + ]); + $middleware = $this->app->make(TracingMiddleware::class); + $request = Request::create('/test', 'GET'); + + $response = $middleware->handle($request, static fn () => new Response('OK')); + $middleware->terminate($request, $response); + + $this->assertSentryTransactionCount(1); + } } class InspectableSentryServiceProvider extends SentryServiceProvider @@ -86,4 +208,66 @@ public function registerMiddlewareForTest(): void { $this->registerMiddleware(); } + + /** + * Register features for inspection. + */ + public function registerFeaturesForTest(): void + { + $this->registerFeatures(); + } + + /** + * Boot features for inspection. + */ + public function bootFeaturesForTest(): void + { + $this->bootFeatures(); + } +} + +class InspectableSentryFeature extends Feature +{ + public function isApplicable(): bool + { + return true; + } + + /** + * Determine if spans can be recorded. + */ + public function canRecordSpansForTest(): bool + { + return $this->canRecordSpans(); + } + + /** + * Determine if breadcrumbs can be recorded. + */ + public function canRecordBreadcrumbsForTest(): bool + { + return $this->canRecordBreadcrumbs(); + } +} + +class FailingRegistrationSentryFeature extends Feature +{ + public RuntimeException $exception; + + public bool $booted = false; + + public function isApplicable(): bool + { + return true; + } + + public function register(): void + { + throw $this->exception; + } + + public function onBoot(): void + { + $this->booted = true; + } } diff --git a/tests/Sentry/ServiceProviderWithoutDsnTest.php b/tests/Sentry/ServiceProviderWithoutDsnTest.php index 2023cabc1..fdc33c917 100644 --- a/tests/Sentry/ServiceProviderWithoutDsnTest.php +++ b/tests/Sentry/ServiceProviderWithoutDsnTest.php @@ -5,10 +5,14 @@ namespace Hypervel\Tests\Sentry; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Di\Aop\AspectCollector; use Hypervel\Routing\Events\RouteMatched; +use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\TestCase; +use ReflectionProperty; class ServiceProviderWithoutDsnTest extends TestCase { @@ -39,6 +43,14 @@ public function testDidNotRegisterEvents(): void $this->assertFalse(app('events')->hasListeners(RouteMatched::class)); } + public function testDidNotRegisterAopOrCoroutinePropagation(): void + { + $callbacks = (new ReflectionProperty(Coroutine::class, 'afterCreatedCallbacks'))->getValue(); + + $this->assertSame([], AspectCollector::getRule(GuzzleHttpClientAspect::class)); + $this->assertSame([], $callbacks); + } + public function testArtisanCommandsAreRegistered(): void { $this->assertArrayHasKey('sentry:test', Artisan::all()); diff --git a/tests/Sentry/Tracing/MiddlewareTest.php b/tests/Sentry/Tracing/MiddlewareTest.php index d52091199..03f9b14d2 100644 --- a/tests/Sentry/Tracing/MiddlewareTest.php +++ b/tests/Sentry/Tracing/MiddlewareTest.php @@ -180,4 +180,40 @@ public function testAfterResponseSpanAppearsOnCapturedTransaction() $this->assertTrue($found, 'After-response span should be captured on the transaction'); } + + public function testTerminateFinishesTransactionWhenAfterResponseTracingIsDisabled(): void + { + config()->set('sentry.tracing.missing_routes', true); + $middleware = new Middleware(false); + $request = Request::create('/test', 'GET'); + + $response = $middleware->handle($request, static fn () => new Response('OK')); + $middleware->terminate($request, $response); + + $this->assertSentryTransactionCount(1); + + $middleware->finishTransaction(); + + $this->assertSentryTransactionCount(1); + } + + public function testIncomingTraceIsContinuedWhenTransactionRecordingIsDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.traces_sample_rate' => null, + ]); + $middleware = $this->app->make(Middleware::class); + $traceId = '5b8efff798038103d269b633813fc60c'; + $request = Request::create('/test', 'GET', server: [ + 'HTTP_SENTRY_TRACE' => "{$traceId}-5e8efff798038103-1", + ]); + + $middleware->handle($request, static fn () => new Response('OK')); + + $this->assertNull(SentrySdk::getCurrentHub()->getTransaction()); + $this->assertSame( + $traceId, + (string) $this->getCurrentSentryScope()->getPropagationContext()->getTraceId(), + ); + } } From c4e3b64b06e71b67c0e8a78deabf5d5e5a812a87 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:00:53 +0000 Subject: [PATCH 10/18] Isolate Sentry hub and span ownership per coroutine Clone inherited mutable Hub layers and requests for child coroutines while preserving client and span pointers, retain placeholder scope state during binding, and keep one authoritative root layer. Give response spans, connection-specific database transactions, Guzzle children, and feature-local spans exact owners without installing operation-local children as Hub current.\n\nAdd bounded coroutine-exit orphan cleanup, catch Throwable at the real event boundaries, and handle failed view-origin reads and null query duration honestly. Cover parent-child-sibling isolation, nested and interleaved connections, local unwind, falsey auth context, and exact span restoration. --- src/sentry/src/EventHandler.php | 46 ++++- .../Concerns/TracksPushedScopesAndSpans.php | 85 ++++++++- src/sentry/src/Hub.php | 59 ++++-- src/sentry/src/Tracing/BacktraceHelper.php | 11 +- src/sentry/src/Tracing/EventHandler.php | 175 +++++++++++++----- .../CoroutineContextPropagationTest.php | 169 +++++++++++------ tests/Sentry/CoroutineSafetyTest.php | 27 +-- tests/Sentry/EventHandler/AuthEventsTest.php | 19 ++ tests/Sentry/EventHandlerTest.php | 48 +++++ .../Features/ViewEngineDecoratorTest.php | 4 + tests/Sentry/HubTest.php | 117 ++++++++++++ tests/Sentry/Tracing/BacktraceHelperTest.php | 39 ++++ tests/Sentry/Tracing/EventHandlerTest.php | 169 +++++++++++++++++ 13 files changed, 817 insertions(+), 151 deletions(-) create mode 100644 tests/Sentry/HubTest.php create mode 100644 tests/Sentry/Tracing/BacktraceHelperTest.php diff --git a/src/sentry/src/EventHandler.php b/src/sentry/src/EventHandler.php index 5a46732fd..8beb9278d 100644 --- a/src/sentry/src/EventHandler.php +++ b/src/sentry/src/EventHandler.php @@ -4,12 +4,12 @@ namespace Hypervel\Sentry; -use Exception; use Hypervel\Auth\Events as AuthEvents; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Core\Events\OnWorkerExit; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Events as DatabaseEvents; use Hypervel\Http\Request; @@ -17,9 +17,13 @@ use Hypervel\Routing\Events as RoutingEvents; use Hypervel\Sanctum\Events as Sanctum; use Hypervel\Sentry\Tracing\Middleware; +use Hypervel\Sentry\Transport\HttpPoolTransport; use RuntimeException; use Sentry\Breadcrumb; +use Sentry\Client; +use Sentry\SentrySdk; use Sentry\State\Scope; +use Throwable; class EventHandler { @@ -32,6 +36,7 @@ class EventHandler LogEvents\MessageLogged::class => 'messageLogged', RoutingEvents\RouteMatched::class => 'routeMatched', DatabaseEvents\QueryExecuted::class => 'queryExecuted', + OnWorkerExit::class => 'workerExit', ]; /** @@ -112,7 +117,7 @@ public function __call(string $method, array $arguments): void try { $this->{$handlerMethod}(...$arguments); - } catch (Exception) { + } catch (Throwable) { // Ignore } } @@ -189,6 +194,26 @@ protected function messageLoggedHandler(LogEvents\MessageLogged $logEntry): void )); } + /** + * Flush buffered telemetry and stop accepting new transport work. + */ + protected function workerExitHandler(OnWorkerExit $event): void + { + try { + Integration::flushEvents(); + } finally { + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client instanceof Client) { + $transport = $client->getTransport(); + + if ($transport instanceof HttpPoolTransport) { + $transport->shutdown(); + } + } + } + } + /** * Handle an authenticated event. */ @@ -230,9 +255,15 @@ private function configureUserScopeFromModel(mixed $authUser): void } } - $username = $this->modelHasAttribute($authUser, 'username') - ? (string) $authUser->getAttribute('username') - : null; + $username = null; + + if ($this->modelHasAttribute($authUser, 'username')) { + $username = $authUser->getAttribute('username'); + + if ($username !== null) { + $username = (string) $username; + } + } $userData = [ 'id' => $authUser instanceof Authenticatable @@ -255,7 +286,10 @@ private function configureUserScopeFromModel(mixed $authUser): void } Integration::configureScope(static function (Scope $scope) use ($userData): void { - $scope->setUser(array_filter($userData)); + $scope->setUser(array_filter( + $userData, + static fn (mixed $value): bool => $value !== null, + )); }); } diff --git a/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php b/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php index c8903b29b..a43b9c93a 100644 --- a/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php +++ b/src/sentry/src/Features/Concerns/TracksPushedScopesAndSpans.php @@ -5,6 +5,7 @@ namespace Hypervel\Sentry\Features\Concerns; use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Sentry\Integration; use Sentry\SentrySdk; use Sentry\Tracing\Span; @@ -15,6 +16,7 @@ * * State is stored in coroutine Context (keyed by the using class name) so that * singleton features sharing this trait remain safe under concurrent coroutines. + * Operation-local spans are tracked by key without becoming current on the Hub. */ trait TracksPushedScopesAndSpans { @@ -34,6 +36,7 @@ protected function pushSpan(Span $span): void $currentStack = CoroutineContext::get($this->contextKey('current_spans'), []); $currentStack[] = $span; CoroutineContext::set($this->contextKey('current_spans'), $currentStack); + $this->registerCleanup(); } /** @@ -45,6 +48,18 @@ protected function pushScope(): void $count = CoroutineContext::get($this->contextKey('scope_count'), 0); CoroutineContext::set($this->contextKey('scope_count'), $count + 1); + $this->registerCleanup(); + } + + /** + * Track an operation-local span without making it current on the Hub. + */ + protected function trackLocalSpan(string $key, Span $span): void + { + $spans = CoroutineContext::get($this->contextKey('local_spans'), []); + $spans[$key] = $span; + CoroutineContext::set($this->contextKey('local_spans'), $spans); + $this->registerCleanup(); } /** @@ -75,14 +90,13 @@ protected function maybePopSpan(): ?Span */ protected function maybePopScope(): void { - Integration::flushEvents(); - $count = CoroutineContext::get($this->contextKey('scope_count'), 0); if ($count === 0) { return; } + Integration::flushEvents(); SentrySdk::getCurrentHub()->popScope(); CoroutineContext::set($this->contextKey('scope_count'), $count - 1); @@ -108,6 +122,30 @@ protected function maybeFinishSpan(?SpanStatus $status = null): ?Span return $span; } + /** + * Finish an operation-local span by key if one exists. + */ + protected function maybeFinishLocalSpan(string $key, ?SpanStatus $status = null): ?Span + { + $spans = CoroutineContext::get($this->contextKey('local_spans'), []); + $span = $spans[$key] ?? null; + + if ($span === null) { + return null; + } + + unset($spans[$key]); + CoroutineContext::set($this->contextKey('local_spans'), $spans); + + if ($status !== null) { + $span->setStatus($status); + } + + $span->finish(); + + return $span; + } + /** * Context key prefix for per-class span tracking state. */ @@ -120,4 +158,47 @@ private function contextKey(string $suffix): string { return self::SPANS_CONTEXT_PREFIX . static::class . '.' . $suffix; } + + /** + * Register cleanup for scopes and spans without terminal events. + */ + private function registerCleanup(): void + { + $cleanupKey = $this->contextKey('cleanup_registered'); + + if (CoroutineContext::get($cleanupKey, false)) { + return; + } + + CoroutineContext::set($cleanupKey, true); + Coroutine::defer(function () use ($cleanupKey): void { + foreach (CoroutineContext::get($this->contextKey('local_spans'), []) as $span) { + if ($span->getEndTimestamp() === null) { + $span->setStatus(SpanStatus::internalError()); + $span->finish(); + } + } + + while (($span = $this->maybePopSpan()) !== null) { + if ($span->getEndTimestamp() === null) { + $span->setStatus(SpanStatus::internalError()); + $span->finish(); + } + } + + $scopeCountKey = $this->contextKey('scope_count'); + $scopeCount = CoroutineContext::get($scopeCountKey, 0); + + while ($scopeCount > 0) { + SentrySdk::getCurrentHub()->popScope(); + --$scopeCount; + } + + CoroutineContext::forget($this->contextKey('parent_spans')); + CoroutineContext::forget($this->contextKey('current_spans')); + CoroutineContext::forget($this->contextKey('local_spans')); + CoroutineContext::forget($scopeCountKey); + CoroutineContext::forget($cleanupKey); + }); + } } diff --git a/src/sentry/src/Hub.php b/src/sentry/src/Hub.php index 373788ecb..43d78a1b5 100644 --- a/src/sentry/src/Hub.php +++ b/src/sentry/src/Hub.php @@ -33,8 +33,6 @@ class Hub implements HubInterface public const CONTEXT_LAST_EVENT_ID_KEY = '__sentry.last_event_id'; - public const CONTEXT_REQUEST_COROUTINE_ID_KEY = '__sentry.coroutine_id'; - public function __construct(protected ?ClientInterface $client = null, protected ?Scope $scope = null) { } @@ -64,19 +62,17 @@ public function getLastEventId(): ?EventId public function pushScope(): Scope { $clonedScope = clone $this->getScope(); - CoroutineContext::override(static::CONTEXT_STACK_KEY, function ($layers) use ($clonedScope) { - $layers = $layers ?? []; - $layers[] = new Layer($this->getClient(), $clonedScope); - - return $layers; - }); + $layers = $this->getStack(); + $layers[] = new Layer($this->getClient(), $clonedScope); + CoroutineContext::set(static::CONTEXT_STACK_KEY, $layers); return $clonedScope; } public function popScope(): bool { - $currentLayers = CoroutineContext::get(static::CONTEXT_STACK_KEY, []); + $currentLayers = $this->getStack(); + if (count($currentLayers) === 1) { return false; // Cannot pop the last scope, as it would leave no layers in the stack } @@ -218,8 +214,9 @@ public function startTransaction(TransactionContext $context, array $customSampl $samplingContext->getParentSampled(), $options->getTracesSampleRate() ?? 0 ); - $sampleSource = $samplingContext->getParentSampled( - ) !== null ? 'parent:sampling_decision' : 'config:traces_sample_rate'; + $sampleSource = $samplingContext->getParentSampled() !== null + ? 'parent:sampling_decision' + : 'config:traces_sample_rate'; } } @@ -289,14 +286,29 @@ public function startTransaction(TransactionContext $context, array $customSampl $transaction->initSpanRecorder(); - $profilesSampleRate = $options->getProfilesSampleRate(); + $profilesSampleSource = 'config:profiles_sample_rate'; + $profilesSampler = $options->getProfilesSampler(); + + if ($profilesSampler !== null) { + $profilesSampleRate = $profilesSampler($samplingContext); + $profilesSampleSource = 'config:profiles_sampler'; + } else { + $profilesSampleRate = $options->getProfilesSampleRate(); + } + if ($profilesSampleRate === null) { $logger->info( sprintf( - 'Transaction [%s] is not profiling because `profiles_sample_rate` option is not set.', + 'Transaction [%s] is not profiling because neither `profiles_sample_rate` nor `profiles_sampler` option is set.', (string) $transaction->getTraceId() ) ); + } elseif (! $this->isValidSampleRate($profilesSampleRate)) { + $logger->warning(sprintf( + 'Transaction [%s] is not profiling because profile sample rate (decided by %s) is invalid.', + (string) $transaction->getTraceId(), + $profilesSampleSource, + )); } elseif ($this->sample($profilesSampleRate)) { $logger->info( sprintf( @@ -396,15 +408,26 @@ protected function getScope(): Scope */ private function getStackTop(): Layer { - $stack = CoroutineContext::getOrSet(self::CONTEXT_STACK_KEY, function () { - $scope = $this->scope ?? new Scope; - - return [new Layer($this->getClient(), $scope)]; - }); + $stack = $this->getStack(); return end($stack); } + /** + * Get the current coroutine's initialized layer stack. + * + * @return list + */ + private function getStack(): array + { + return CoroutineContext::getOrSet(static::CONTEXT_STACK_KEY, function (): array { + return [new Layer( + $this->getClient(), + clone ($this->scope ?? new Scope), + )]; + }); + } + private function sample(mixed $sampleRate): bool { if ($sampleRate === 0.0 || $sampleRate === null) { diff --git a/src/sentry/src/Tracing/BacktraceHelper.php b/src/sentry/src/Tracing/BacktraceHelper.php index 8c225b455..f894be271 100644 --- a/src/sentry/src/Tracing/BacktraceHelper.php +++ b/src/sentry/src/Tracing/BacktraceHelper.php @@ -76,12 +76,17 @@ public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?st return null; } - // If for some reason the file does not exists, skip resolving - if (! file_exists($frame->getAbsoluteFilePath())) { + $absoluteFilePath = $frame->getAbsoluteFilePath(); + + if ($absoluteFilePath === null) { return null; } - $viewFileContents = file_get_contents($frame->getAbsoluteFilePath()); + $viewFileContents = @file_get_contents($absoluteFilePath); + + if ($viewFileContents === false) { + return null; + } preg_match('/PATH (?.*?) ENDPATH/', $viewFileContents, $matches); diff --git a/src/sentry/src/Tracing/EventHandler.php b/src/sentry/src/Tracing/EventHandler.php index d979796b4..dd0692149 100644 --- a/src/sentry/src/Tracing/EventHandler.php +++ b/src/sentry/src/Tracing/EventHandler.php @@ -4,9 +4,9 @@ namespace Hypervel\Sentry\Tracing; -use Exception; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Events as DatabaseEvents; use Hypervel\Routing\Events as RoutingEvents; use Hypervel\Sentry\Features\Concerns\ResolvesEventOrigin; @@ -17,6 +17,7 @@ use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanStatus; use Symfony\Component\HttpFoundation\Response; +use Throwable; class EventHandler { @@ -37,9 +38,11 @@ class EventHandler DatabaseEvents\TransactionRolledBack::class => 'transactionRolledBack', ]; - private const CONTEXT_PARENT_SPANS_KEY = '__sentry.tracing.parent_spans'; + private const CONTEXT_RESPONSE_SPANS_KEY = '__sentry.tracing.response_spans'; - public const CONTEXT_CURRENT_SPANS_KEY = '__sentry.tracing.current_spans'; + public const CONTEXT_TRANSACTION_SPANS_KEY = '__sentry.tracing.transaction_spans'; + + private const CONTEXT_CLEANUP_REGISTERED_KEY = '__sentry.tracing.cleanup_registered'; private readonly bool $traceSqlQueries; @@ -87,7 +90,7 @@ public function __call(string $method, array $arguments): void try { $this->{$handlerMethod}(...$arguments); - } catch (Exception) { + } catch (Throwable) { // Ignore to prevent bubbling up errors in the SDK } } @@ -114,13 +117,18 @@ protected function routeMatchedHandler(RoutingEvents\RouteMatched $match): void */ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): void { - $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + $transactionSpans = CoroutineContext::get(self::CONTEXT_TRANSACTION_SPANS_KEY, []); + $connectionSpans = $transactionSpans[spl_object_id($query->connection)] ?? []; + $parentSpan = $connectionSpans === [] + ? SentrySdk::getCurrentHub()->getSpan() + : end($connectionSpans); // If there is no sampled span there is no need to handle the event if ($parentSpan === null || ! $parentSpan->getSampled()) { return; } + $now = microtime(true); $context = SpanContext::make() ->setOp('db.sql.query') ->setData([ @@ -130,10 +138,17 @@ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): vo 'server.port' => $query->connection->getConfig('port'), ]) ->setOrigin('auto.db') - ->setDescription($query->sql) - ->setStartTimestamp(microtime(true) - $query->time / 1000); - - $context->setEndTimestamp($context->getStartTimestamp() + $query->time / 1000); + ->setDescription($query->sql); + + if ($query->time === null) { + $context + ->setStartTimestamp($now) + ->setEndTimestamp($now); + } else { + $context + ->setStartTimestamp($now - $query->time / 1000) + ->setEndTimestamp($now); + } if ($this->traceSqlBindings) { $context->setData(array_merge($context->getData(), [ @@ -141,7 +156,9 @@ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): vo ])); } - if ($this->traceSqlQueryOrigin && $query->time >= $this->traceSqlQueryOriginThresholdMs) { + if ($this->traceSqlQueryOrigin + && $query->time !== null + && $query->time >= $this->traceSqlQueryOriginThresholdMs) { $queryOrigin = $this->resolveEventOrigin(); if ($queryOrigin !== null) { @@ -157,7 +174,7 @@ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): vo */ protected function responsePreparedHandler(RoutingEvents\ResponsePrepared $event): void { - $span = $this->popSpan(); + $span = $this->popResponseSpan(); if ($span !== null) { $span->finish(); @@ -182,12 +199,13 @@ protected function responsePreparingHandler(RoutingEvents\PreparingResponse $eve return; } - $this->pushSpan( + $this->pushResponseSpan( $parentSpan->startChild( SpanContext::make() ->setOp('http.route.response') ->setOrigin('auto.http.server') - ) + ), + $parentSpan, ); } @@ -196,19 +214,25 @@ protected function responsePreparingHandler(RoutingEvents\PreparingResponse $eve */ protected function transactionBeginningHandler(DatabaseEvents\TransactionBeginning $event): void { - $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + $connectionId = spl_object_id($event->connection); + $transactionSpans = CoroutineContext::get(self::CONTEXT_TRANSACTION_SPANS_KEY, []); + $connectionSpans = $transactionSpans[$connectionId] ?? []; + $parentSpan = $connectionSpans === [] + ? SentrySdk::getCurrentHub()->getSpan() + : end($connectionSpans); if ($parentSpan === null || ! $parentSpan->getSampled()) { return; } - $this->pushSpan( - $parentSpan->startChild( - SpanContext::make() - ->setOp('db.transaction') - ->setOrigin('auto.db') - ) + $connectionSpans[] = $parentSpan->startChild( + SpanContext::make() + ->setOp('db.transaction') + ->setOrigin('auto.db') ); + $transactionSpans[$connectionId] = $connectionSpans; + CoroutineContext::set(self::CONTEXT_TRANSACTION_SPANS_KEY, $transactionSpans); + $this->registerCleanup(); } /** @@ -216,7 +240,7 @@ protected function transactionBeginningHandler(DatabaseEvents\TransactionBeginni */ protected function transactionCommittedHandler(DatabaseEvents\TransactionCommitted $event): void { - $span = $this->popSpan(); + $span = $this->popTransactionSpan($event); if ($span !== null) { $span->setStatus(SpanStatus::ok()); @@ -229,7 +253,7 @@ protected function transactionCommittedHandler(DatabaseEvents\TransactionCommitt */ protected function transactionRolledBackHandler(DatabaseEvents\TransactionRolledBack $event): void { - $span = $this->popSpan(); + $span = $this->popTransactionSpan($event); if ($span !== null) { $span->setStatus(SpanStatus::internalError()); @@ -238,43 +262,106 @@ protected function transactionRolledBackHandler(DatabaseEvents\TransactionRolled } /** - * Push a span onto the coroutine-local stack and set it as current on the hub. + * Push a response span and install it as current on the Hub. + */ + private function pushResponseSpan(Span $span, Span $parent): void + { + $responseSpans = CoroutineContext::get(self::CONTEXT_RESPONSE_SPANS_KEY, []); + $responseSpans[] = ['span' => $span, 'parent' => $parent]; + CoroutineContext::set(self::CONTEXT_RESPONSE_SPANS_KEY, $responseSpans); + SentrySdk::getCurrentHub()->setSpan($span); + $this->registerCleanup(); + } + + /** + * Pop a response span and restore its parent on the Hub. */ - private function pushSpan(Span $span): void + private function popResponseSpan(): ?Span { - $hub = SentrySdk::getCurrentHub(); + $responseSpans = CoroutineContext::get(self::CONTEXT_RESPONSE_SPANS_KEY, []); - $parentStack = CoroutineContext::get(self::CONTEXT_PARENT_SPANS_KEY, []); - $parentStack[] = $hub->getSpan(); - CoroutineContext::set(self::CONTEXT_PARENT_SPANS_KEY, $parentStack); + if ($responseSpans === []) { + return null; + } - $hub->setSpan($span); + $entry = array_pop($responseSpans); + CoroutineContext::set(self::CONTEXT_RESPONSE_SPANS_KEY, $responseSpans); + SentrySdk::getCurrentHub()->setSpan($entry['parent']); - $currentStack = CoroutineContext::get(self::CONTEXT_CURRENT_SPANS_KEY, []); - $currentStack[] = $span; - CoroutineContext::set(self::CONTEXT_CURRENT_SPANS_KEY, $currentStack); + return $entry['span']; } /** - * Pop a span from the coroutine-local stack and restore the parent span. + * Pop the current transaction span for an exact connection. */ - private function popSpan(): ?Span - { - $currentStack = CoroutineContext::get(self::CONTEXT_CURRENT_SPANS_KEY, []); - - if ($currentStack === []) { + private function popTransactionSpan( + DatabaseEvents\TransactionCommitted|DatabaseEvents\TransactionRolledBack $event + ): ?Span { + $transactionSpans = CoroutineContext::get(self::CONTEXT_TRANSACTION_SPANS_KEY, []); + $connectionId = spl_object_id($event->connection); + $connectionSpans = $transactionSpans[$connectionId] ?? []; + + if ($connectionSpans === []) { return null; } - $parentStack = CoroutineContext::get(self::CONTEXT_PARENT_SPANS_KEY, []); - $parent = array_pop($parentStack); - CoroutineContext::set(self::CONTEXT_PARENT_SPANS_KEY, $parentStack); + $span = array_pop($connectionSpans); - SentrySdk::getCurrentHub()->setSpan($parent); + if ($connectionSpans === []) { + unset($transactionSpans[$connectionId]); + } else { + $transactionSpans[$connectionId] = $connectionSpans; + } - $span = array_pop($currentStack); - CoroutineContext::set(self::CONTEXT_CURRENT_SPANS_KEY, $currentStack); + CoroutineContext::set(self::CONTEXT_TRANSACTION_SPANS_KEY, $transactionSpans); return $span; } + + /** + * Register cleanup for response and transaction spans without terminals. + */ + private function registerCleanup(): void + { + if (CoroutineContext::get(self::CONTEXT_CLEANUP_REGISTERED_KEY, false)) { + return; + } + + CoroutineContext::set(self::CONTEXT_CLEANUP_REGISTERED_KEY, true); + Coroutine::defer(static function (): void { + $hub = SentrySdk::getCurrentHub(); + $responseSpans = CoroutineContext::get(self::CONTEXT_RESPONSE_SPANS_KEY, []); + + while (($entry = array_pop($responseSpans)) !== null) { + $hub->setSpan($entry['parent']); + self::finishAbandonedSpan($entry['span']); + } + + CoroutineContext::forget(self::CONTEXT_RESPONSE_SPANS_KEY); + + $transactionSpans = CoroutineContext::get(self::CONTEXT_TRANSACTION_SPANS_KEY, []); + + foreach ($transactionSpans as $connectionSpans) { + while (($span = array_pop($connectionSpans)) !== null) { + self::finishAbandonedSpan($span); + } + } + + CoroutineContext::forget(self::CONTEXT_TRANSACTION_SPANS_KEY); + CoroutineContext::forget(self::CONTEXT_CLEANUP_REGISTERED_KEY); + }); + } + + /** + * Finish an abandoned span as an internal failure. + */ + private static function finishAbandonedSpan(Span $span): void + { + if ($span->getEndTimestamp() !== null) { + return; + } + + $span->setStatus(SpanStatus::internalError()); + $span->finish(); + } } diff --git a/tests/Sentry/CoroutineContextPropagationTest.php b/tests/Sentry/CoroutineContextPropagationTest.php index 1fca01348..2d1b79e8c 100644 --- a/tests/Sentry/CoroutineContextPropagationTest.php +++ b/tests/Sentry/CoroutineContextPropagationTest.php @@ -8,102 +8,149 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Sentry\Hub; +use Sentry\Event; +use Sentry\State\Layer; use Sentry\State\Scope; +use Swoole\Coroutine\Channel; class CoroutineContextPropagationTest extends SentryTestCase { - public function testChildCoroutineInheritsSentryStackFromParent() + public function testOrdinaryChildCoroutinesCloneParentSentryState(): void { - // Set up a Sentry scope stack in the parent coroutine $hub = $this->getSentryHubFromContainer(); $hub->pushScope(); - $hub->configureScope(function (Scope $scope) { - $scope->setTag('test_tag', 'parent_value'); + $hub->configureScope(static function (Scope $scope): void { + $scope->setTag('owner', 'parent'); }); + $span = $this->startTransaction(); + $request = Request::create('/test?owner=parent'); + CoroutineContext::set(Request::class, $request); + /** @var list $parentStack */ $parentStack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); - $this->assertNotNull($parentStack); - - $childStack = null; - - $channel = new \Swoole\Coroutine\Channel(1); - - Coroutine::create(function () use (&$childStack, $channel) { - $childStack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); - $channel->push(true); + $results = new Channel(2); + + Coroutine::create(function () use ($hub, $results): void { + /** @var list $stack */ + $stack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); + /** @var Request $request */ + $request = CoroutineContext::get(Request::class); + $hub->configureScope(static function (Scope $scope): void { + $scope->setTag('child', 'first'); + }); + $request->query->set('owner', 'first'); + + $results->push([$stack, $request, $this->scopeTags($hub)]); }); - $channel->pop(1.0); + Coroutine::create(function () use ($hub, $results): void { + $results->push([ + CoroutineContext::get(Hub::CONTEXT_STACK_KEY), + CoroutineContext::get(Request::class), + $this->scopeTags($hub), + ]); + }); - $this->assertNotNull($childStack, 'Child coroutine should inherit the Sentry scope stack from parent'); - $this->assertSame($parentStack, $childStack, 'Child coroutine should have the same scope stack reference as parent'); + [$firstStack, $firstRequest, $firstTags] = $results->pop(1.0); + [$secondStack, $secondRequest, $secondTags] = $results->pop(1.0); + + foreach ([$firstStack, $secondStack] as $childStack) { + $this->assertNotSame($parentStack, $childStack); + $this->assertCount(count($parentStack), $childStack); + + foreach ($childStack as $index => $layer) { + $this->assertNotSame($parentStack[$index], $layer); + $this->assertNotSame($parentStack[$index]->getScope(), $layer->getScope()); + $this->assertSame($parentStack[$index]->getClient(), $layer->getClient()); + } + + $this->assertSame($span, end($childStack)->getScope()->getSpan()); + } + + $this->assertNotSame($request, $firstRequest); + $this->assertNotSame($request, $secondRequest); + $this->assertNotSame($firstRequest, $secondRequest); + $this->assertSame('first', $firstRequest->query('owner')); + $this->assertSame('parent', $secondRequest->query('owner')); + $this->assertSame('parent', $request->query('owner')); + $this->assertSame(['owner' => 'parent', 'child' => 'first'], $firstTags); + $this->assertSame(['owner' => 'parent'], $secondTags); + $this->assertSame(['owner' => 'parent'], $this->scopeTags($hub)); } - public function testChildCoroutineInheritsRequestContextFromParent() + public function testForkClonesTheInstalledContextSnapshot(): void { - // Set up a request in the parent coroutine context - $request = Request::create('/test', 'GET'); + $hub = $this->getSentryHubFromContainer(); + $hub->pushScope(); + $request = Request::create('/fork'); CoroutineContext::set(Request::class, $request); + /** @var list $parentStack */ + $parentStack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); + $result = new Channel(1); - $childRequest = null; - - $channel = new \Swoole\Coroutine\Channel(1); - - Coroutine::create(function () use (&$childRequest, $channel) { - $childRequest = CoroutineContext::get(Request::class); - $channel->push(true); + Coroutine::fork(static function () use ($result): void { + $result->push([ + CoroutineContext::get(Hub::CONTEXT_STACK_KEY), + CoroutineContext::get(Request::class), + ]); }); - $channel->pop(1.0); + [$childStack, $childRequest] = $result->pop(1.0); - $this->assertNotNull($childRequest, 'Child coroutine should inherit the Request context from parent'); - $this->assertSame($request, $childRequest, 'Child coroutine should have the same Request instance as parent'); + $this->assertNotSame($parentStack, $childStack); + $this->assertNotSame($parentStack[0], $childStack[0]); + $this->assertNotSame($parentStack[0]->getScope(), $childStack[0]->getScope()); + $this->assertNotSame($request, $childRequest); } - public function testChildCoroutineInheritsBothSentryStackAndRequest() + public function testSelectiveForkStillPropagatesSentryInfrastructureContext(): void { - // Set up both Sentry stack and request context $hub = $this->getSentryHubFromContainer(); $hub->pushScope(); - - $request = Request::create('/both-test', 'POST'); + $request = Request::create('/selective'); CoroutineContext::set(Request::class, $request); - - $parentStack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); - - $childStack = null; - $childRequest = null; - - $channel = new \Swoole\Coroutine\Channel(1); - - Coroutine::create(function () use (&$childStack, &$childRequest, $channel) { - $childStack = CoroutineContext::get(Hub::CONTEXT_STACK_KEY); - $childRequest = CoroutineContext::get(Request::class); - $channel->push(true); - }); - - $channel->pop(1.0); - - $this->assertSame($parentStack, $childStack); - $this->assertSame($request, $childRequest); + CoroutineContext::set('selected', 'value'); + $result = new Channel(1); + + Coroutine::fork(static function () use ($result): void { + $result->push([ + CoroutineContext::get(Hub::CONTEXT_STACK_KEY), + CoroutineContext::get(Request::class), + CoroutineContext::get('selected'), + ]); + }, ['selected']); + + [$childStack, $childRequest, $selected] = $result->pop(1.0); + + $this->assertNotNull($childStack); + $this->assertNotSame($request, $childRequest); + $this->assertSame('value', $selected); } - public function testChildCoroutineWithoutParentContextGetsNull() + public function testChildWithoutParentRequestContextGetsNull(): void { - // Ensure no request is set in the parent CoroutineContext::forget(Request::class); + $result = new Channel(1); - $childRequest = 'sentinel'; + Coroutine::create(static function () use ($result): void { + $result->push(CoroutineContext::get(Request::class) ?? 'missing'); + }); - $channel = new \Swoole\Coroutine\Channel(1); + $this->assertSame('missing', $result->pop(1.0)); + } - Coroutine::create(function () use (&$childRequest, $channel) { - $childRequest = CoroutineContext::get(Request::class); - $channel->push(true); + /** + * Get the tags applied by the current Hub scope. + * + * @return array + */ + private function scopeTags(Hub $hub): array + { + $event = Event::createEvent(); + $hub->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); }); - $channel->pop(1.0); - - $this->assertNull($childRequest, 'Child coroutine should not have Request context when parent has none'); + return $event->getTags(); } } diff --git a/tests/Sentry/CoroutineSafetyTest.php b/tests/Sentry/CoroutineSafetyTest.php index 88ba1f019..a0a095dad 100644 --- a/tests/Sentry/CoroutineSafetyTest.php +++ b/tests/Sentry/CoroutineSafetyTest.php @@ -6,15 +6,13 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; +use Hypervel\Database\Connection; +use Hypervel\Database\Events\TransactionBeginning; use Hypervel\Sentry\Features\CacheFeature; use Hypervel\Sentry\Integration; use Hypervel\Sentry\Tracing\EventHandler as TracingEventHandler; use Hypervel\Tests\TestCase; -use ReflectionMethod; use Sentry\SentrySdk; -use Sentry\Tracing\Span; -use Sentry\Tracing\SpanContext; -use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; use Swoole\Coroutine\Channel; @@ -63,12 +61,16 @@ public function testTracingEventHandlerSpanStacksAreIsolatedPerCoroutine() $transaction->setSampled(true); $hub->setSpan($transaction); - // Push a span in the parent coroutine via a mock DB transaction event - $parentSpan = $transaction->startChild(SpanContext::make()->setOp('test.parent')); - $this->pushSpanOnHandler($handler, $parentSpan); + $connection = new Connection( + static fn (): null => null, + 'database', + '', + ['driver' => 'sqlite', 'name' => 'parent'], + ); + $handler->transactionBeginning(new TransactionBeginning($connection)); // Verify parent has a span on its stack - $parentStackKey = TracingEventHandler::CONTEXT_CURRENT_SPANS_KEY; + $parentStackKey = TracingEventHandler::CONTEXT_TRANSACTION_SPANS_KEY; $parentStack = CoroutineContext::get($parentStackKey, []); $this->assertCount(1, $parentStack); @@ -122,13 +124,4 @@ public function testTracksPushedScopesAndSpansTraitIsIsolatedPerCoroutine() $this->assertSame(3, CoroutineContext::get($scopeKey, 0)); $this->assertCount(3, CoroutineContext::get($currentSpansKey, [])); } - - /** - * Use reflection to call the private pushSpan method on TracingEventHandler. - */ - private function pushSpanOnHandler(TracingEventHandler $handler, Span $span): void - { - $method = new ReflectionMethod($handler, 'pushSpan'); - $method->invoke($handler, $span); - } } diff --git a/tests/Sentry/EventHandler/AuthEventsTest.php b/tests/Sentry/EventHandler/AuthEventsTest.php index e6ce8c723..65d46f049 100644 --- a/tests/Sentry/EventHandler/AuthEventsTest.php +++ b/tests/Sentry/EventHandler/AuthEventsTest.php @@ -127,6 +127,25 @@ public function testAuthenticatedEventDoesNotSetEmailOnScopeWhenEmailAttributeIs $this->assertNull($scope->getUser()->getEmail()); } + public function testAuthenticatedEventPreservesFalseyUserFields(): void + { + $user = new AuthEventsTestUserModel; + + $user->forceFill([ + 'id' => 0, + 'username' => '', + 'email' => '', + ]); + + $this->dispatchHypervelEvent(new Authenticated('test', $user)); + + $sentryUser = $this->getCurrentSentryScope()->getUser(); + $this->assertNotNull($sentryUser); + $this->assertSame(0, $sentryUser->getId()); + $this->assertSame('', $sentryUser->getUsername()); + $this->assertSame('', $sentryUser->getEmail()); + } + public function testAuthenticatedEventDoesNotFillUserOnScopeWhenPIIShouldNotBeSent(): void { $this->resetApplicationWithConfig([ diff --git a/tests/Sentry/EventHandlerTest.php b/tests/Sentry/EventHandlerTest.php index 0752340d8..2a0250615 100644 --- a/tests/Sentry/EventHandlerTest.php +++ b/tests/Sentry/EventHandlerTest.php @@ -4,9 +4,18 @@ namespace Hypervel\Tests\Sentry; +use Hypervel\Core\Events\OnWorkerExit; use Hypervel\Sentry\EventHandler; +use Hypervel\Sentry\Transport\HttpPoolTransport; +use Mockery as m; use ReflectionClass; use RuntimeException; +use Sentry\Client; +use Sentry\Event; +use Sentry\SentrySdk; +use Sentry\State\Hub; +use Sentry\Transport\ResultStatus; +use Swoole\Server; class EventHandlerTest extends SentryTestCase { @@ -34,6 +43,45 @@ public function testAllMappedAuthEventHandlersExist(): void ); } + public function testWorkerExitListenerClosesTheTransportPool(): void + { + $client = $this->getSentryClientFromContainer(); + $this->assertInstanceOf(Client::class, $client); + + $transport = $client->getTransport(); + $this->assertInstanceOf(HttpPoolTransport::class, $transport); + + $this->dispatchHypervelEvent(new OnWorkerExit(m::mock(Server::class), 1)); + + $this->assertSame( + ResultStatus::skipped(), + $transport->send(Event::createEvent())->getStatus(), + ); + } + + public function testWorkerExitClosesTheTransportPoolWhenFlushFails(): void + { + $transport = m::mock(HttpPoolTransport::class); + $transport->shouldReceive('shutdown')->once(); + $client = m::mock(Client::class); + $client->shouldReceive('flush') + ->once() + ->with(null) + ->andThrow(new RuntimeException('Unable to flush.')); + $client->shouldReceive('getTransport') + ->once() + ->andReturn($transport); + $previousHub = SentrySdk::getCurrentHub(); + SentrySdk::setCurrentHub(new Hub($client)); + + try { + $handler = new EventHandler($this->app, []); + $handler->workerExit(new OnWorkerExit(m::mock(Server::class), 1)); + } finally { + SentrySdk::setCurrentHub($previousHub); + } + } + private function tryAllEventHandlerMethods(array $methods): void { $handler = new EventHandler($this->app, []); diff --git a/tests/Sentry/Features/ViewEngineDecoratorTest.php b/tests/Sentry/Features/ViewEngineDecoratorTest.php index 5ca724d07..cade70cb3 100644 --- a/tests/Sentry/Features/ViewEngineDecoratorTest.php +++ b/tests/Sentry/Features/ViewEngineDecoratorTest.php @@ -15,6 +15,10 @@ class ViewEngineDecoratorTest extends SentryTestCase { + protected array $defaultSetupConfig = [ + 'sentry.traces_sample_rate' => 1.0, + ]; + public function testViewEngineIsDecorated(): void { /** @var EngineResolver $engineResolver */ diff --git a/tests/Sentry/HubTest.php b/tests/Sentry/HubTest.php new file mode 100644 index 000000000..6c4f9f5a9 --- /dev/null +++ b/tests/Sentry/HubTest.php @@ -0,0 +1,117 @@ +setTag('baseline', 'yes'); + $hub = new Hub(scope: $baseline); + $root = null; + + $hub->configureScope(static function (Scope $scope) use (&$root): void { + $root = $scope; + }); + + $client = m::mock(ClientInterface::class); + $hub->bindClient($client); + + $this->assertNotSame($baseline, $root); + $this->assertSame($client, $hub->getClient()); + $this->assertSame(['baseline' => 'yes'], $this->scopeTags($hub)); + } + + public function testEveryCoroutineRootClonesTheBaselineScope(): void + { + $baseline = new Scope; + $baseline->setTag('baseline', 'yes'); + $hub = new Hub(scope: $baseline); + $firstReady = new Channel(1); + $releaseFirst = new Channel(1); + $results = new Channel(2); + + Coroutine::create(function () use ($firstReady, $hub, $releaseFirst, $results): void { + $hub->configureScope(static function (Scope $scope): void { + $scope->setTag('child', 'first'); + }); + $firstReady->push(true); + $releaseFirst->pop(); + $results->push($this->scopeTags($hub)); + }); + $this->assertTrue($firstReady->pop(1.0)); + + Coroutine::create(function () use ($hub, $results): void { + $results->push($this->scopeTags($hub)); + }); + + $secondTags = $results->pop(1.0); + $releaseFirst->push(true); + $firstTags = $results->pop(1.0); + + $this->assertSame(['baseline' => 'yes'], $secondTags); + $this->assertSame(['baseline' => 'yes', 'child' => 'first'], $firstTags); + $this->assertSame(['baseline' => 'yes'], $this->scopeTags($hub)); + } + + public function testFinalRootLayerCannotBePoppedBeforeOrAfterNestedScopes(): void + { + $hub = new Hub; + + $this->assertFalse($hub->popScope()); + + $hub->pushScope(); + + $this->assertTrue($hub->popScope()); + $this->assertFalse($hub->popScope()); + } + + public function testProfilesSamplerControlsProfileSampling(): void + { + $called = false; + $options = new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function () use (&$called): float { + $called = true; + + return 0.0; + }, + ]); + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->once()->andReturn($options); + $transaction = (new Hub($client))->startTransaction(new TransactionContext('test')); + + $this->assertTrue($called); + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + /** + * Get the tags applied by the current Hub scope. + * + * @return array + */ + private function scopeTags(Hub $hub): array + { + $event = Event::createEvent(); + $hub->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + return $event->getTags(); + } +} diff --git a/tests/Sentry/Tracing/BacktraceHelperTest.php b/tests/Sentry/Tracing/BacktraceHelperTest.php new file mode 100644 index 000000000..86cf46aad --- /dev/null +++ b/tests/Sentry/Tracing/BacktraceHelperTest.php @@ -0,0 +1,39 @@ +deleteDirectory($directory); + $filesystem->makeDirectory($directory); + $socketPath = "{$directory}/compiled-view.sock"; + $socket = stream_socket_server("unix://{$socketPath}"); + + $this->assertIsResource($socket); + + try { + $options = new Options(['dsn' => null]); + $helper = new BacktraceHelper($options, new RepresentationSerializer($options)); + $frame = new Frame(null, '/storage/framework/views/compiled.php', 1, absoluteFilePath: $socketPath); + + $this->assertNull($helper->getOriginalViewPathForFrameOfCompiledViewPath($frame)); + } finally { + fclose($socket); + $filesystem->deleteDirectory($directory); + } + } +} diff --git a/tests/Sentry/Tracing/EventHandlerTest.php b/tests/Sentry/Tracing/EventHandlerTest.php index 1093527eb..569b61fa6 100644 --- a/tests/Sentry/Tracing/EventHandlerTest.php +++ b/tests/Sentry/Tracing/EventHandlerTest.php @@ -4,13 +4,34 @@ namespace Hypervel\Tests\Sentry\Tracing; +use Error; +use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Database\Connection; +use Hypervel\Database\Events\QueryExecuted; +use Hypervel\Database\Events\TransactionBeginning; +use Hypervel\Database\Events\TransactionCommitted; +use Hypervel\Database\Events\TransactionRolledBack; +use Hypervel\Http\Request; +use Hypervel\Routing\Events\PreparingResponse; +use Hypervel\Routing\Events\ResponsePrepared; use Hypervel\Sentry\Tracing\EventHandler; use Hypervel\Tests\Sentry\SentryTestCase; +use Mockery as m; use ReflectionClass; use RuntimeException; +use Sentry\SentrySdk; +use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; +use Swoole\Coroutine\Channel; +use Symfony\Component\HttpFoundation\Response; class EventHandlerTest extends SentryTestCase { + protected array $defaultSetupConfig = [ + 'sentry.traces_sample_rate' => 1.0, + ]; + public function testMissingEventHandlerThrowsException(): void { $this->expectException(RuntimeException::class); @@ -28,6 +49,132 @@ public function testAllMappedEventHandlersExist(): void ); } + public function testTransactionsAndQueriesAreOwnedByTheirExactConnection(): void + { + $handler = new EventHandler([]); + $transaction = $this->startTransaction(); + $firstConnection = $this->connection('first'); + $secondConnection = $this->connection('second'); + + $handler->transactionBeginning(new TransactionBeginning($firstConnection)); + $first = $this->currentTransactionSpan($firstConnection); + $handler->transactionBeginning(new TransactionBeginning($secondConnection)); + $second = $this->currentTransactionSpan($secondConnection); + $handler->queryExecuted(new QueryExecuted('select first', [], 2.0, $firstConnection)); + $firstQuery = $this->lastRecordedSpan($transaction); + $handler->transactionBeginning(new TransactionBeginning($firstConnection)); + $nestedFirst = $this->currentTransactionSpan($firstConnection); + $handler->queryExecuted(new QueryExecuted('select nested', [], 2.0, $firstConnection)); + $nestedQuery = $this->lastRecordedSpan($transaction); + $handler->transactionCommitted(new TransactionCommitted($firstConnection)); + $handler->queryExecuted(new QueryExecuted('select outer', [], 2.0, $firstConnection)); + $outerQuery = $this->lastRecordedSpan($transaction); + $handler->transactionRolledBack(new TransactionRolledBack($secondConnection)); + $handler->transactionCommitted(new TransactionCommitted($firstConnection)); + + $this->assertEquals($transaction->getSpanId(), $first->getParentSpanId()); + $this->assertEquals($transaction->getSpanId(), $second->getParentSpanId()); + $this->assertEquals($first->getSpanId(), $firstQuery->getParentSpanId()); + $this->assertEquals($first->getSpanId(), $nestedFirst->getParentSpanId()); + $this->assertEquals($nestedFirst->getSpanId(), $nestedQuery->getParentSpanId()); + $this->assertEquals($first->getSpanId(), $outerQuery->getParentSpanId()); + $this->assertSame(SpanStatus::ok(), $nestedFirst->getStatus()); + $this->assertSame(SpanStatus::ok(), $first->getStatus()); + $this->assertSame(SpanStatus::internalError(), $second->getStatus()); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + $this->assertSame([], CoroutineContext::get(EventHandler::CONTEXT_TRANSACTION_SPANS_KEY, [])); + } + + public function testResponseAndTransactionSpansUseIndependentOwnership(): void + { + $handler = new EventHandler([]); + $transaction = $this->startTransaction(); + $request = Request::create('/response'); + $connection = $this->connection('response'); + + $handler->responsePreparing(new PreparingResponse($request, 'payload')); + $responseSpan = SentrySdk::getCurrentHub()->getSpan(); + $handler->transactionBeginning(new TransactionBeginning($connection)); + $databaseSpan = $this->currentTransactionSpan($connection); + + $this->assertSame($responseSpan, SentrySdk::getCurrentHub()->getSpan()); + $this->assertEquals($responseSpan->getSpanId(), $databaseSpan->getParentSpanId()); + + $handler->responsePrepared(new ResponsePrepared($request, new Response)); + + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + $this->assertNotNull($responseSpan->getEndTimestamp()); + $this->assertNull($databaseSpan->getEndTimestamp()); + + $handler->transactionCommitted(new TransactionCommitted($connection)); + + $this->assertSame(SpanStatus::ok(), $databaseSpan->getStatus()); + } + + public function testNullQueryTimeCreatesAnInstantaneousSpanWithoutOriginResolution(): void + { + $handler = new EventHandler([ + 'sql_origin' => true, + 'sql_origin_threshold_ms' => 0, + ]); + $transaction = $this->startTransaction(); + + $handler->queryExecuted(new QueryExecuted( + 'select without timing', + [], + null, + $this->connection('untimed'), + )); + + $span = $this->lastRecordedSpan($transaction); + $this->assertSame($span->getStartTimestamp(), $span->getEndTimestamp()); + $this->assertArrayNotHasKey('code.filepath', $span->getData()); + } + + public function testThrowableFromInstrumentationDoesNotReachApplicationCode(): void + { + $this->startTransaction(); + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('throwing'); + $connection->shouldReceive('getDatabaseName')->once()->andThrow(new Error('broken instrumentation')); + $handler = new EventHandler([]); + + $handler->queryExecuted(new QueryExecuted('select 1', [], 1.0, $connection)); + + $this->addToAssertionCount(1); + } + + public function testCoroutineExitFinishesOnlyAbandonedResponseAndTransactionSpans(): void + { + $this->startTransaction(); + $result = new Channel(1); + $observedRestoredSpan = null; + + $coroutineId = Coroutine::create(function () use (&$observedRestoredSpan, $result): void { + $hub = SentrySdk::getCurrentHub(); + $root = $hub->getSpan(); + Coroutine::defer(static function () use (&$observedRestoredSpan): void { + $observedRestoredSpan = SentrySdk::getCurrentHub()->getSpan(); + }); + $handler = new EventHandler([]); + $connection = $this->connection('abandoned'); + $handler->responsePreparing(new PreparingResponse(Request::create('/abandoned'), 'payload')); + $responseSpan = $hub->getSpan(); + $handler->transactionBeginning(new TransactionBeginning($connection)); + $transactionSpan = $this->currentTransactionSpan($connection); + $result->push([$root, $responseSpan, $transactionSpan]); + }); + + [$root, $responseSpan, $transactionSpan] = $result->pop(1.0); + Coroutine::join([$coroutineId], 1.0); + + $this->assertSame($root, $observedRestoredSpan); + $this->assertSame(SpanStatus::internalError(), $responseSpan->getStatus()); + $this->assertSame(SpanStatus::internalError(), $transactionSpan->getStatus()); + $this->assertNotNull($responseSpan->getEndTimestamp()); + $this->assertNotNull($transactionSpan->getEndTimestamp()); + } + private function tryAllEventHandlerMethods(array $methods): void { $handler = new EventHandler([]); @@ -49,4 +196,26 @@ private function getEventHandlerMapFromEventHandler(): array return $attributes['eventHandlerMap']; } + + private function connection(string $name): Connection + { + return new Connection( + static fn (): null => null, + 'database', + '', + ['driver' => 'sqlite', 'name' => $name], + ); + } + + private function currentTransactionSpan(Connection $connection): Span + { + $transactionSpans = CoroutineContext::get(EventHandler::CONTEXT_TRANSACTION_SPANS_KEY, []); + + return end($transactionSpans[spl_object_id($connection)]); + } + + private function lastRecordedSpan(Span $transaction): Span + { + return last($transaction->getSpanRecorder()->getSpans()); + } } From ac76c8c8e335cb6528d047416896ce0bf6d2f2f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:05 +0000 Subject: [PATCH 11/18] Trace exact cache operation terminals Consume the framework's resolved single and many-key success and failure events without a pending-operation registry or Laravel's associative-key normalizer. Finish each sampled span from its exact terminal while retaining breadcrumb behavior and skipping all listener registration when neither output is possible.\n\nShare guarded session-key resolution for cache and Redis instrumentation, preferring already-resolved state and request cookies before a reentry-protected fallback. Cover read, write, forget, batch, failure, no-output, and session-resolution paths. --- src/sentry/src/Features/CacheFeature.php | 115 ++++-------------- .../Features/Concerns/ResolvesSessionKey.php | 97 +++++++++++++++ .../Sentry/Features/CacheIntegrationTest.php | 88 ++++++++++++++ 3 files changed, 208 insertions(+), 92 deletions(-) create mode 100644 src/sentry/src/Features/Concerns/ResolvesSessionKey.php diff --git a/src/sentry/src/Features/CacheFeature.php b/src/sentry/src/Features/CacheFeature.php index 141b909be..3a740998a 100644 --- a/src/sentry/src/Features/CacheFeature.php +++ b/src/sentry/src/Features/CacheFeature.php @@ -4,22 +4,23 @@ namespace Hypervel\Sentry\Features; -use Exception; use Hypervel\Cache\Events\CacheEvent; use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; use Hypervel\Cache\Events\ForgettingKey; use Hypervel\Cache\Events\KeyForgetFailed; use Hypervel\Cache\Events\KeyForgotten; +use Hypervel\Cache\Events\KeyRetrievalFailed; use Hypervel\Cache\Events\KeyWriteFailed; use Hypervel\Cache\Events\KeyWritten; +use Hypervel\Cache\Events\ManyKeysRetrievalFailed; use Hypervel\Cache\Events\RetrievingKey; use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Contracts\Session\Session; use Hypervel\Sentry\Features\Concerns\ResolvesEventOrigin; +use Hypervel\Sentry\Features\Concerns\ResolvesSessionKey; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; use Hypervel\Sentry\Features\Concerns\WorksWithSpans; use Hypervel\Sentry\Integration; @@ -33,6 +34,7 @@ class CacheFeature extends Feature use WorksWithSpans; use TracksPushedScopesAndSpans; use ResolvesEventOrigin; + use ResolvesSessionKey; /** * Indicates whether to attempt to detect the session key when running in the console. @@ -75,6 +77,8 @@ public function onBoot(): void RetrievingManyKeys::class, CacheHit::class, CacheMissed::class, + KeyRetrievalFailed::class, + ManyKeysRetrievalFailed::class, WritingKey::class, WritingManyKeys::class, @@ -129,11 +133,8 @@ public function handleCacheEventsForTracing(CacheEvent $event): void $this->withParentSpanIfSampled(function (Span $parentSpan) use ($event) { if ($event instanceof RetrievingKey || $event instanceof RetrievingManyKeys) { - $keys = $this->normalizeKeyOrKeys( - $event instanceof RetrievingKey - ? [$event->key] - : $event->keys - ); + // Hypervel cache events contain resolved string keys, so upstream normalization is unnecessary. + $keys = $event instanceof RetrievingKey ? [$event->key] : $event->keys; $displayKeys = $this->replaceSessionKeys($keys); @@ -151,11 +152,7 @@ public function handleCacheEventsForTracing(CacheEvent $event): void } if ($event instanceof WritingKey || $event instanceof WritingManyKeys) { - $keys = $this->normalizeKeyOrKeys( - $event instanceof WritingKey - ? [$event->key] - : $event->keys - ); + $keys = $event instanceof WritingKey ? [$event->key] : $event->keys; $displayKeys = $this->replaceSessionKeys($keys); @@ -194,10 +191,18 @@ public function handleCacheEventsForTracing(CacheEvent $event): void protected function maybeHandleCacheEventAsEndOfSpan(CacheEvent $event): bool { // End of span for RetrievingKey and RetrievingManyKeys events - if ($event instanceof CacheHit || $event instanceof CacheMissed) { - $finishedSpan = $this->maybeFinishSpan(SpanStatus::ok()); + if ($event instanceof CacheHit + || $event instanceof CacheMissed + || $event instanceof KeyRetrievalFailed + || $event instanceof ManyKeysRetrievalFailed) { + $failed = $event instanceof KeyRetrievalFailed || $event instanceof ManyKeysRetrievalFailed; + $finishedSpan = $this->maybeFinishSpan( + $failed ? SpanStatus::internalError() : SpanStatus::ok() + ); - if ($finishedSpan !== null && count($finishedSpan->getData()['cache.key'] ?? []) === 1) { + if (! $failed + && $finishedSpan !== null + && count($finishedSpan->getData()['cache.key'] ?? []) === 1) { $finishedSpan->setData([ 'cache.hit' => $event instanceof CacheHit, ]); @@ -221,87 +226,13 @@ protected function maybeHandleCacheEventAsEndOfSpan(CacheEvent $event): bool // End of span for ForgettingKey event if ($event instanceof KeyForgotten || $event instanceof KeyForgetFailed) { - $this->maybeFinishSpan(); + $this->maybeFinishSpan( + $event instanceof KeyForgotten ? SpanStatus::ok() : SpanStatus::internalError() + ); return true; } return false; } - - /** - * Retrieve the current session key if available. - */ - private function getSessionKey(): ?string - { - try { - // Skip session resolution in the console to avoid unnecessary database connections - // (e.g. when using a database session driver during `artisan cache:clear`) - if (! $this->detectSessionKeyOnConsole && app()->runningInConsole()) { - return null; - } - - /** @var Session $sessionStore */ - $sessionStore = $this->container->make('session.store'); - - // It is safe for us to get the session ID here without checking if the session is started - // because getting the session ID does not start the session. In addition we need the ID before - // the session is started because the cache will retrieve the session ID from the cache before the session - // is considered started. So if we wait for the session to be started, we will not be able to replace the - // session key in the cache operation that is being executed to retrieve the session data from the cache. - return $sessionStore->getId(); - } catch (Exception) { - // We can assume the session store is not available here so there is no session key to retrieve - // We capture a generic exception to avoid breaking the application because some code paths can - // result in an exception other than the expected `Hypervel\Contracts\Container\BindingResolutionException` - return null; - } - } - - /** - * Replace a session key with a placeholder. - */ - private function replaceSessionKey(?string $value): string - { - if (! is_string($value)) { - return '{empty key}'; - } - - return $value === $this->getSessionKey() ? '{sessionKey}' : $value; - } - - /** - * Replace session keys in an array of keys with placeholders. - * - * @param string[] $values - * - * @return mixed[] - */ - private function replaceSessionKeys(array $values): array - { - $sessionKey = $this->getSessionKey(); - - return array_map(static function ($value) use ($sessionKey) { - // @phpstan-ignore function.alreadyNarrowedType (defensive: event data may contain non-strings) - return is_string($value) && $value === $sessionKey ? '{sessionKey}' : $value; - }, $values); - } - - /** - * Normalize the array of keys to a array of only strings. - * - * @param array|string|string[] $keyOrKeys - * - * @return string[] - */ - private function normalizeKeyOrKeys(array|string $keyOrKeys): array - { - if (is_string($keyOrKeys)) { - return [$keyOrKeys]; - } - - return collect($keyOrKeys)->map(function ($value, $key) { - return is_string($key) ? $key : $value; - })->values()->all(); - } } diff --git a/src/sentry/src/Features/Concerns/ResolvesSessionKey.php b/src/sentry/src/Features/Concerns/ResolvesSessionKey.php new file mode 100644 index 000000000..8087d522c --- /dev/null +++ b/src/sentry/src/Features/Concerns/ResolvesSessionKey.php @@ -0,0 +1,97 @@ +detectSessionKeyOnConsole && app()->runningInConsole()) { + return null; + } + + try { + if ($this->container->resolved('session.store')) { + return $this->resolvedSessionKey(); + } + + $request = RequestContext::getOrNull(); + + if ($request !== null) { + $cookieName = $this->container->make('config')->string('session.cookie'); + $sessionKey = $request->cookies->get($cookieName); + + if (is_string($sessionKey)) { + return $sessionKey; + } + } + + if (CoroutineContext::get(self::SESSION_KEY_RESOLUTION_CONTEXT_KEY, false) === true) { + return null; + } + + CoroutineContext::set(self::SESSION_KEY_RESOLUTION_CONTEXT_KEY, true); + + try { + return $this->resolvedSessionKey(); + } finally { + CoroutineContext::forget(self::SESSION_KEY_RESOLUTION_CONTEXT_KEY); + } + } catch (Throwable) { + return null; + } + } + + /** + * Retrieve the session key from the session store. + */ + private function resolvedSessionKey(): ?string + { + /** @var Session $sessionStore */ + $sessionStore = $this->container->make('session.store'); + + return $sessionStore->getId(); + } + + /** + * Replace a session key with a placeholder. + */ + private function replaceSessionKey(string $value): string + { + return $value === $this->getSessionKey() ? self::SESSION_KEY_PLACEHOLDER : $value; + } + + /** + * Replace session keys in an array of keys with placeholders. + * + * @param array $values + * + * @return array + */ + private function replaceSessionKeys(array $values): array + { + // Resolve once per command; non-string parameters, including null, must pass through unchanged. + $sessionKey = $this->getSessionKey(); + + return array_map( + static fn (mixed $value): mixed => is_string($value) && $value === $sessionKey + ? self::SESSION_KEY_PLACEHOLDER + : $value, + $values + ); + } +} diff --git a/tests/Sentry/Features/CacheIntegrationTest.php b/tests/Sentry/Features/CacheIntegrationTest.php index 5984f8be3..a8893548e 100644 --- a/tests/Sentry/Features/CacheIntegrationTest.php +++ b/tests/Sentry/Features/CacheIntegrationTest.php @@ -5,10 +5,15 @@ namespace Hypervel\Tests\Sentry\Features; use Hypervel\Cache\Events\RetrievingKey; +use Hypervel\Cache\Repository; +use Hypervel\Contracts\Cache\Store; use Hypervel\Sentry\Features\CacheFeature; use Hypervel\Support\Facades\Cache; use Hypervel\Tests\Sentry\SentryTestCase; +use Mockery as m; +use RuntimeException; use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; class CacheIntegrationTest extends SentryTestCase { @@ -188,6 +193,63 @@ public function testCacheRemoveSpanIsRecorded(): void $this->assertEquals(['foo'], $span->getData()['cache.key']); } + public function testCacheGetFailureFinishesItsSpanAndRethrows(): void + { + $exception = new RuntimeException('The cache read failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('get')->once()->with('foo')->andThrow($exception); + + $span = $this->executeFailureAndReturnMostRecentSpan( + fn () => $this->repository($store)->get('foo'), + $exception, + ); + + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + } + + public function testCacheManyFailureFinishesItsSpanAndRethrows(): void + { + $exception = new RuntimeException('The cache batch read failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('many')->once()->with(['foo', 'bar'])->andThrow($exception); + + $span = $this->executeFailureAndReturnMostRecentSpan( + fn () => $this->repository($store)->many(['foo', 'bar']), + $exception, + ); + + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + } + + public function testCachePutFailureFinishesItsSpanAndRethrows(): void + { + $exception = new RuntimeException('The cache write failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('put')->once()->with('foo', 'bar', 60)->andThrow($exception); + + $span = $this->executeFailureAndReturnMostRecentSpan( + fn () => $this->repository($store)->put('foo', 'bar', 60), + $exception, + ); + + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + $this->assertFalse($span->getData()['cache.success']); + } + + public function testCacheForgetFailureFinishesItsSpanAndRethrows(): void + { + $exception = new RuntimeException('The cache forget failed.'); + $store = m::mock(Store::class); + $store->shouldReceive('forget')->once()->with('foo')->andThrow($exception); + + $span = $this->executeFailureAndReturnMostRecentSpan( + fn () => $this->repository($store)->forget('foo'), + $exception, + ); + + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + } + public function testCacheSpanReplacesSessionKeyWithPlaceholder(): void { $this->markSkippedIfTracingEventsNotAvailable(); @@ -258,4 +320,30 @@ private function executeAndReturnMostRecentSpan(callable $callable): Span return array_pop($spans); } + + private function executeFailureAndReturnMostRecentSpan(callable $callable, RuntimeException $exception): Span + { + $transaction = $this->startTransaction(); + + try { + $callable(); + $this->fail('Expected the cache exception to be rethrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + + return array_pop($spans); + } + + private function repository(Store $store): Repository + { + $repository = new Repository($store, ['store' => 'test']); + $repository->setEventDispatcher($this->app->make('events')); + + return $repository; + } } From 43e1f69e11baed317a451237ff795ddd2b9ed1ef Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:14 +0000 Subject: [PATCH 12/18] Respect Redis PII and session-key boundaries Omit Redis command parameters unless default PII is enabled, then redact only exact string matches for the active session key while preserving falsey keys and non-string arguments. Resolve session state through the shared bounded concern and retain existing pool metrics and nullable duration semantics.\n\nRemove duplicated success and failure recording without adding request-time resolution or context state on the ordinary fast paths. Cover disabled PII, redaction, key zero, cookies, resolved stores, guarded fallback, and resolution failures. --- src/sentry/src/Features/RedisFeature.php | 152 ++++-------------- .../Sentry/Features/RedisIntegrationTest.php | 95 +++++++++++ 2 files changed, 122 insertions(+), 125 deletions(-) diff --git a/src/sentry/src/Features/RedisFeature.php b/src/sentry/src/Features/RedisFeature.php index 8227c11c5..2f3aedec9 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -4,8 +4,6 @@ namespace Hypervel\Sentry\Features; -use Exception; -use Hypervel\Contracts\Session\Session; use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; @@ -13,13 +11,16 @@ use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisManager; use Hypervel\Sentry\Features\Concerns\ResolvesEventOrigin; +use Hypervel\Sentry\Features\Concerns\ResolvesSessionKey; use Hypervel\Support\Str; use Sentry\SentrySdk; use Sentry\Tracing\SpanContext; +use Sentry\Tracing\SpanStatus; class RedisFeature extends Feature { use ResolvesEventOrigin; + use ResolvesSessionKey; /** * Indicates whether to attempt to detect the session key when running in the console. @@ -46,72 +47,21 @@ public function onBoot(): void public function handleRedisCommands(CommandExecuted $event): void { - $parentSpan = SentrySdk::getCurrentHub()->getSpan(); - - // If there is no sampled span there is no need to handle the event - if ($parentSpan === null || ! $parentSpan->getSampled()) { - return; - } - - $pool = $this->container->make(PoolFactory::class)->getPool($event->connectionName); - $redisConfig = $this->container->make(RedisConfig::class); - $config = $redisConfig->connectionConfig($event->connectionName); - - $keyForDescription = ''; - - // If the first parameter is a string and does not contain a newline we use it as the description since it's most likely a key - // This is not a perfect solution but it's the best we can do without understanding the command that was executed - if (! empty($event->parameters[0]) && is_string($event->parameters[0]) && ! Str::contains( - $event->parameters[0], - "\n" - )) { - $keyForDescription = $this->replaceSessionKey($event->parameters[0]); - } - - $redisStatement = rtrim(strtoupper($event->command) . ' ' . $keyForDescription); - - $data = [ - 'coroutine.id' => Coroutine::id(), - 'db.system' => 'redis', - 'db.statement' => $redisStatement, - 'db.redis.connection' => $event->connectionName, - 'db.redis.database_index' => (int) ($config['database'] ?? 0), - 'db.redis.parameters' => $event->parameters, - 'db.redis.pool.name' => $event->connectionName, - 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), - 'db.redis.pool.max_idle_time' => $pool->getOption()->getMaxIdleTime(), - 'db.redis.pool.idle' => $pool->getConnectionsInChannel(), - 'db.redis.pool.using' => $pool->getCurrentConnections(), - 'duration' => $event->time, - ]; - - $context = SpanContext::make() - ->setOp('db.redis') - ->setOrigin('auto.cache.redis') - ->setDescription($redisStatement); - $context->setStartTimestamp(microtime(true) - $event->time / 1000); - $context->setEndTimestamp($context->getStartTimestamp() + $event->time / 1000); - - if ($this->shouldSendDefaultPii()) { - $data['db.redis.parameters'] = $this->replaceSessionKeys($event->parameters); - } - - if ($this->isTracingFeatureEnabled('redis_origin')) { - $commandOrigin = $this->resolveEventOrigin(); - - if ($commandOrigin !== null) { - $data = array_merge($data, $commandOrigin); - } - } - $context->setData($data); - - $parentSpan->startChild($context); + $this->recordCommand($event); } /** * Record a failed Redis command as an error span. */ public function handleFailedRedisCommands(CommandFailed $event): void + { + $this->recordCommand($event); + } + + /** + * Record a completed Redis command. + */ + private function recordCommand(CommandExecuted|CommandFailed $event): void { $parentSpan = SentrySdk::getCurrentHub()->getSpan(); @@ -124,12 +74,15 @@ public function handleFailedRedisCommands(CommandFailed $event): void $config = $redisConfig->connectionConfig($event->connectionName); $keyForDescription = ''; + $firstParameter = $event->parameters[0] ?? null; - if (! empty($event->parameters[0]) && is_string($event->parameters[0]) && ! Str::contains( - $event->parameters[0], + // If the first parameter is a string and does not contain a newline we use it as the description since it's most likely a key. + // This is not a perfect solution but it's the best we can do without understanding the command that was executed. + if (is_string($firstParameter) && $firstParameter !== '' && ! Str::contains( + $firstParameter, "\n" )) { - $keyForDescription = $this->replaceSessionKey($event->parameters[0]); + $keyForDescription = $this->replaceSessionKey($firstParameter); } $redisStatement = rtrim(strtoupper($event->command) . ' ' . $keyForDescription); @@ -140,20 +93,25 @@ public function handleFailedRedisCommands(CommandFailed $event): void 'db.statement' => $redisStatement, 'db.redis.connection' => $event->connectionName, 'db.redis.database_index' => (int) ($config['database'] ?? 0), - 'db.redis.parameters' => $event->parameters, 'db.redis.pool.name' => $event->connectionName, 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), 'db.redis.pool.max_idle_time' => $pool->getOption()->getMaxIdleTime(), 'db.redis.pool.idle' => $pool->getConnectionsInChannel(), 'db.redis.pool.using' => $pool->getCurrentConnections(), - 'db.redis.error' => $event->exception->getMessage(), ]; + if ($event instanceof CommandFailed) { + $data['db.redis.error'] = $event->exception->getMessage(); + } + $context = SpanContext::make() ->setOp('db.redis') ->setOrigin('auto.cache.redis') - ->setDescription($redisStatement) - ->setStatus(\Sentry\Tracing\SpanStatus::internalError()); + ->setDescription($redisStatement); + + if ($event instanceof CommandFailed) { + $context->setStatus(SpanStatus::internalError()); + } if ($event->time !== null) { $context->setStartTimestamp(microtime(true) - $event->time / 1000); @@ -181,60 +139,4 @@ public function handleFailedRedisCommands(CommandFailed $event): void $parentSpan->startChild($context); } - - /** - * Retrieve the current session key if available. - */ - private function getSessionKey(): ?string - { - try { - // Skip session resolution in the console to avoid unnecessary database connections - // (e.g. when using a database session driver during artisan commands) - if (! $this->detectSessionKeyOnConsole && app()->runningInConsole()) { - return null; - } - - /** @var Session $sessionStore */ - $sessionStore = $this->container->make('session.store'); - - // It is safe for us to get the session ID here without checking if the session is started - // because getting the session ID does not start the session. In addition we need the ID before - // the session is started because the cache will retrieve the session ID from the cache before the session - // is considered started. So if we wait for the session to be started, we will not be able to replace the - // session key in the cache operation that is being executed to retrieve the session data from the cache. - return $sessionStore->getId(); - } catch (Exception) { - // We can assume the session store is not available here so there is no session key to retrieve - // We capture a generic exception to avoid breaking the application because some code paths can - // result in an exception other than the expected `Hypervel\Contracts\Container\BindingResolutionException` - return null; - } - } - - /** - * Replace session keys in an array of keys with placeholders. - * - * @param string[] $values - */ - private function replaceSessionKeys(array $values): array - { - $sessionKey = $this->getSessionKey(); - - return array_map(static function ($value) use ($sessionKey) { - // @phpstan-ignore function.alreadyNarrowedType (defensive: event data may contain non-strings) - return is_string($value) && $value === $sessionKey ? '{sessionKey}' : $value; - }, $values); - } - - /** - * Replace a session key with a placeholder. - */ - private function replaceSessionKey(?string $value): string - { - if (! is_string($value)) { - return '{empty key}'; - } - - return $value === $this->getSessionKey() ? '{sessionKey}' : $value; - } } diff --git a/tests/Sentry/Features/RedisIntegrationTest.php b/tests/Sentry/Features/RedisIntegrationTest.php index a3f1d6948..6cecdadf9 100644 --- a/tests/Sentry/Features/RedisIntegrationTest.php +++ b/tests/Sentry/Features/RedisIntegrationTest.php @@ -4,9 +4,13 @@ namespace Hypervel\Tests\Sentry\Features; +use Error; use Exception; +use Hypervel\Context\RequestContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Pool\PoolOptionInterface; +use Hypervel\Contracts\Session\Session; +use Hypervel\Http\Request; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\PhpRedisConnection; @@ -97,6 +101,7 @@ public function testRedisCommandCreatesSpanWhenParentSpanExists(): void $this->assertEquals('GET test-key', $spanData['db.statement']); $this->assertEquals('default', $spanData['db.redis.connection']); $this->assertEquals(0.005, $spanData['duration']); + $this->assertArrayNotHasKey('db.redis.parameters', $spanData); } public function testRedisCommandWithSessionKeyReplacesWithPlaceholder(): void @@ -119,6 +124,96 @@ public function testRedisCommandWithSessionKeyReplacesWithPlaceholder(): void $this->assertEquals('GET {sessionKey}', $redisSpan->getData()['db.statement']); } + public function testRedisParametersRequirePiiConsentAndRedactSessionKey(): void + { + $this->resetApplicationWithConfig(['sentry.send_default_pii' => true]); + $this->app->make(RedisFeature::class)->detectSessionKeyOnConsole = true; + $this->setupMocks(); + $this->startSession(); + $sessionId = $this->app['session']->getId(); + $transaction = $this->startTransaction(); + + $dispatcher = $this->app->make(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $dispatcher->dispatch(new CommandExecuted('SET', [$sessionId, 'value'], 0.005, $connection)); + + $redisSpan = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertSame(['{sessionKey}', 'value'], $redisSpan->getData()['db.redis.parameters']); + } + + public function testRedisKeyZeroIsPreservedInDescription(): void + { + $this->setupMocks(); + $transaction = $this->startTransaction(); + + $dispatcher = $this->app->make(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $dispatcher->dispatch(new CommandExecuted('GET', ['0'], 0.005, $connection)); + + $redisSpan = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertSame('GET 0', $redisSpan->getDescription()); + $this->assertSame('GET 0', $redisSpan->getData()['db.statement']); + } + + public function testRedisSessionKeyUsesCurrentRequestCookieBeforeResolvingStore(): void + { + $this->setupMocks(); + $this->assertFalse($this->app->resolved('session.store')); + $cookieName = $this->app->make('config')->string('session.cookie'); + $request = Request::create('/'); + $request->cookies->set($cookieName, 'cookie-session'); + RequestContext::set($request); + $transaction = $this->startTransaction(); + + $dispatcher = $this->app->make(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $dispatcher->dispatch(new CommandExecuted('GET', ['cookie-session'], 0.005, $connection)); + + $redisSpan = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertSame('GET {sessionKey}', $redisSpan->getDescription()); + $this->assertFalse($this->app->resolved('session.store')); + } + + public function testRedisSessionKeyFallbackIsReentrySafe(): void + { + $this->setupMocks(); + $dispatcher = $this->app->make(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $session = m::mock(Session::class); + $session->shouldReceive('getId')->once()->andReturn('outer-key'); + $this->app->bind('session.store', function () use ($dispatcher, $connection, $session): Session { + $dispatcher->dispatch(new CommandExecuted('GET', ['inner-key'], 0.005, $connection)); + + return $session; + }); + $transaction = $this->startTransaction(); + + $dispatcher->dispatch(new CommandExecuted('GET', ['outer-key'], 0.005, $connection)); + + $spans = $transaction->getSpanRecorder()->getSpans(); + $this->assertCount(3, $spans); + $this->assertSame('GET inner-key', $spans[1]->getDescription()); + $this->assertSame('GET {sessionKey}', $spans[2]->getDescription()); + } + + public function testRedisSessionResolutionThrowableDoesNotBreakCommandTracing(): void + { + $this->setupMocks(); + $this->app->bind('session.store', static fn (): never => throw new Error('Session resolution failed.')); + $transaction = $this->startTransaction(); + + $dispatcher = $this->app->make(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $dispatcher->dispatch(new CommandExecuted('GET', ['test-key'], 0.005, $connection)); + + $redisSpan = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertSame('GET test-key', $redisSpan->getDescription()); + } + public function testRedisCommandWithoutParentSpanDoesNotCreateSpan(): void { $this->setupMocks(); From 47621b96e7c78cb2c4907b5fcc30f419289d59b5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:23 +0000 Subject: [PATCH 13/18] Finish notification spans at delivery terminals Close notification delivery spans on delivered, failed, or skipped events while retaining the existing NotificationSent breadcrumb. This gives the span the real channel-delivery boundary and avoids relabeling post-delivery callback failures as transport failures.\n\nKeep local orphan cleanup as a bounded final safety net and add regression coverage for success, veto, failure, event order, and exact span status. --- .../src/Features/NotificationsFeature.php | 18 ++++++++-- .../Features/NotificationsIntegrationTest.php | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/sentry/src/Features/NotificationsFeature.php b/src/sentry/src/Features/NotificationsFeature.php index 857e5dac8..dc2d3671e 100644 --- a/src/sentry/src/Features/NotificationsFeature.php +++ b/src/sentry/src/Features/NotificationsFeature.php @@ -5,8 +5,11 @@ namespace Hypervel\Sentry\Features; use Hypervel\Database\Eloquent\Model; +use Hypervel\Notifications\Events\NotificationDelivered; +use Hypervel\Notifications\Events\NotificationFailed; use Hypervel\Notifications\Events\NotificationSending; use Hypervel\Notifications\Events\NotificationSent; +use Hypervel\Notifications\Events\NotificationSkipped; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; use Hypervel\Sentry\Integration; use Sentry\Breadcrumb; @@ -31,6 +34,11 @@ public function onBoot(): void $dispatcher = $this->container->make('events'); if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { $dispatcher->listen(NotificationSending::class, [$this, 'handleNotificationSending']); + $dispatcher->listen([ + NotificationDelivered::class, + NotificationFailed::class, + NotificationSkipped::class, + ], [$this, 'handleNotificationTerminal']); } $dispatcher->listen(NotificationSent::class, [$this, 'handleNotificationSent']); @@ -61,8 +69,6 @@ public function handleNotificationSending(NotificationSending $event): void public function handleNotificationSent(NotificationSent $event): void { - $this->maybeFinishSpan(SpanStatus::ok()); - if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { Integration::addBreadcrumb( new Breadcrumb( @@ -80,6 +86,14 @@ public function handleNotificationSent(NotificationSent $event): void } } + public function handleNotificationTerminal( + NotificationDelivered|NotificationFailed|NotificationSkipped $event + ): void { + $this->maybeFinishSpan( + $event instanceof NotificationFailed ? SpanStatus::internalError() : SpanStatus::ok() + ); + } + private function formatNotifiable($notifiable): string { if (is_string($notifiable) || is_numeric($notifiable)) { diff --git a/tests/Sentry/Features/NotificationsIntegrationTest.php b/tests/Sentry/Features/NotificationsIntegrationTest.php index ddf0c96f6..d7b1ba2bf 100644 --- a/tests/Sentry/Features/NotificationsIntegrationTest.php +++ b/tests/Sentry/Features/NotificationsIntegrationTest.php @@ -6,6 +6,9 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Notifications\Events\NotificationFailed; +use Hypervel\Notifications\Events\NotificationSending; +use Hypervel\Notifications\Events\NotificationSkipped; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Sentry\Features\NotificationsFeature; use Hypervel\Support\Facades\Mail; @@ -41,6 +44,36 @@ public function testSpanIsRecorded(): void $this->assertEquals(SpanStatus::ok(), $span->getStatus()); } + public function testFailedNotificationFinishesItsSpanWithAnError(): void + { + $notification = new NotificationsIntegrationTestNotification; + $notification->id = 'notification-id'; + $transaction = $this->startTransaction(); + + $this->dispatchHypervelEvent(new NotificationSending('notifiable', $notification, 'mail')); + $this->dispatchHypervelEvent(new NotificationFailed('notifiable', $notification, 'mail')); + + $span = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertNotNull($span->getEndTimestamp()); + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + } + + public function testSkippedNotificationFinishesItsSpanSuccessfully(): void + { + $notification = new NotificationsIntegrationTestNotification; + $notification->id = 'notification-id'; + $transaction = $this->startTransaction(); + + $this->dispatchHypervelEvent(new NotificationSending('notifiable', $notification, 'mail')); + $this->dispatchHypervelEvent(new NotificationSkipped('notifiable', $notification, 'mail')); + + $span = $transaction->getSpanRecorder()->getSpans()[1]; + + $this->assertNotNull($span->getEndTimestamp()); + $this->assertSame(SpanStatus::ok(), $span->getStatus()); + } + public function testSpanIsNotRecordedWhenDisabled(): void { $this->resetApplicationWithConfig([ From 4160500de9a5a8f10b114ebbc9a7b2a04bfbd829 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:35 +0000 Subject: [PATCH 14/18] Correlate queue spans with exact enqueue terminals Start sampled publication spans only when an enqueue attempt begins, carry the resolved destination through the payload hook, and correlate out-of-order success and failure terminals by the unchanged payload. Keep propagation metadata when local recording is disabled and never install publication children as Hub current.\n\nAdd per-job sampling middleware and use bounded drains only for graceful console and queue-worker lifecycles, skipping immediate and max-memory termination. Cover resolved default queues, mixed terminals, orphan cleanup, propagation-only mode, sampling, and flush ordering. --- .../src/Features/ConsoleIntegration.php | 3 +- src/sentry/src/Features/QueueFeature.php | 104 ++++---- .../Middleware/SentryTracesSampleRate.php | 75 ++++++ .../Sentry/Features/QueueIntegrationTest.php | 178 +++++++++++++ tests/Sentry/FlushLifecycleTest.php | 238 ++++++++++++++---- .../Middleware/SentryTracesSampleRateTest.php | 150 +++++++++++ 6 files changed, 650 insertions(+), 98 deletions(-) create mode 100644 src/sentry/src/Jobs/Middleware/SentryTracesSampleRate.php create mode 100644 tests/Sentry/Jobs/Middleware/SentryTracesSampleRateTest.php diff --git a/src/sentry/src/Features/ConsoleIntegration.php b/src/sentry/src/Features/ConsoleIntegration.php index 5c984816e..6a11d6a9a 100644 --- a/src/sentry/src/Features/ConsoleIntegration.php +++ b/src/sentry/src/Features/ConsoleIntegration.php @@ -66,8 +66,7 @@ public function commandFinished(ConsoleEvents\CommandFinished $event): void )); } - // Flush any and all events that were possibly generated by the command - Integration::flushEvents(); + Integration::drainEvents(); Integration::configureScope(static function (Scope $scope): void { $scope->removeTag('command'); diff --git a/src/sentry/src/Features/QueueFeature.php b/src/sentry/src/Features/QueueFeature.php index c6aafcc1d..498381281 100644 --- a/src/sentry/src/Features/QueueFeature.php +++ b/src/sentry/src/Features/QueueFeature.php @@ -11,8 +11,10 @@ use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\Queue; +use Hypervel\Queue\WorkerStopReason; use Hypervel\Sentry\Features\Concerns\TracksPushedScopesAndSpans; use Hypervel\Sentry\Integration; use Hypervel\Support\Str; @@ -43,64 +45,56 @@ class QueueFeature extends Feature private const QUEUE_PAYLOAD_PUBLISH_TIME = 'sentry_publish_time'; + private const QUEUE_PAYLOAD_DESTINATION_NAME = 'sentry_destination_name'; + public function isApplicable(): bool { - if (! $this->container->bound('queue')) { - return false; - } - - return $this->isBreadcrumbFeatureEnabled('queue_info') - || $this->isTracingFeatureEnabled('queue_jobs') - || $this->isTracingFeatureEnabled('queue_job_transactions'); + return $this->container->bound('queue'); } public function onBoot(): void { $dispatcher = $this->container->make('events'); - $dispatcher->listen(JobQueueing::class, [$this, 'handleJobQueueingEvent']); - $dispatcher->listen(JobQueued::class, [$this, 'handleJobQueuedEvent']); + $recordSpans = $this->isTracingFeatureEnabled('queue_jobs') + || $this->isTracingFeatureEnabled('queue_job_transactions'); + $recordBreadcrumbs = $this->isBreadcrumbFeatureEnabled('queue_info'); + + if ($recordSpans) { + $dispatcher->listen(JobQueueing::class, [$this, 'handleJobQueueingEvent']); + $dispatcher->listen(JobQueued::class, [$this, 'handleJobQueuedEvent']); + $dispatcher->listen(JobQueueingFailed::class, [$this, 'handleJobQueueingFailedEvent']); + } + + if ($recordSpans || $recordBreadcrumbs) { + $dispatcher->listen(JobProcessed::class, [$this, 'handleJobProcessedQueueEvent']); + $dispatcher->listen(JobProcessing::class, [$this, 'handleJobProcessingQueueEvent']); + $dispatcher->listen(JobFailed::class, [$this, 'handleJobFailedEvent']); + } - $dispatcher->listen(JobProcessed::class, [$this, 'handleJobProcessedQueueEvent']); - $dispatcher->listen(JobProcessing::class, [$this, 'handleJobProcessingQueueEvent']); - $dispatcher->listen(JobFailed::class, [$this, 'handleJobFailedEvent']); $dispatcher->listen(WorkerStopping::class, [$this, 'handleWorkerStoppingQueueEvent']); $dispatcher->listen(JobExceptionOccurred::class, [$this, 'handleJobExceptionOccurredQueueEvent']); - if ($this->isTracingFeatureEnabled('queue_jobs') || $this->isTracingFeatureEnabled('queue_job_transactions')) { - Queue::createPayloadUsing(function (?string $connection, ?string $queue, ?array $payload): ?array { - $parentSpan = SentrySdk::getCurrentHub()->getSpan(); - - if ($parentSpan !== null && $parentSpan->getSampled()) { - $context = (new SpanContext) - ->setOp(self::QUEUE_SPAN_OP_QUEUE_PUBLISH) - ->setData([ - 'messaging.system' => 'hypervel', - 'messaging.message.id' => $payload['uuid'] ?? null, - 'messaging.destination.name' => $this->normalizeQueueName($queue), - 'messaging.destination.connection' => $connection, - ]) - ->setDescription($queue); - - $this->pushSpan($parentSpan->startChild($context)); - } + Queue::createPayloadUsing(function (?string $connection, ?string $queue, ?array $payload) use ($recordSpans): ?array { + if ($payload !== null) { + $payload[self::QUEUE_PAYLOAD_BAGGAGE_DATA] = getBaggage(); + $payload[self::QUEUE_PAYLOAD_TRACE_PARENT_DATA] = getTraceparent(); + $payload[self::QUEUE_PAYLOAD_PUBLISH_TIME] = microtime(true); - if ($payload !== null) { - $payload[self::QUEUE_PAYLOAD_BAGGAGE_DATA] = getBaggage(); - $payload[self::QUEUE_PAYLOAD_TRACE_PARENT_DATA] = getTraceparent(); - $payload[self::QUEUE_PAYLOAD_PUBLISH_TIME] = microtime(true); + if ($recordSpans) { + $payload[self::QUEUE_PAYLOAD_DESTINATION_NAME] = $queue; } + } - return $payload; - }); - } + return $payload; + }); } public function handleJobQueueingEvent(JobQueueing $event): void { - $currentSpan = SentrySdk::getCurrentHub()->getSpan(); + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); // If there is no tracing span active there is no need to handle the event - if ($currentSpan === null || $currentSpan->getOp() !== self::QUEUE_SPAN_OP_QUEUE_PUBLISH) { + if ($parentSpan === null || ! $parentSpan->getSampled()) { return; } @@ -112,13 +106,34 @@ public function handleJobQueueingEvent(JobQueueing $event): void $jobName = get_class($jobName); } - $currentSpan + $payload = $event->payload(); + $destination = $payload[self::QUEUE_PAYLOAD_DESTINATION_NAME] ?? null; + + if (! is_string($destination)) { + $destination = $event->queue; + } + + $context = (new SpanContext) + ->setOp(self::QUEUE_SPAN_OP_QUEUE_PUBLISH) + ->setData([ + 'messaging.system' => 'hypervel', + 'messaging.message.id' => $payload['uuid'] ?? null, + 'messaging.destination.name' => $this->normalizeQueueName($destination), + 'messaging.destination.connection' => $event->connectionName, + ]) ->setDescription($jobName); + + $this->trackLocalSpan($event->payload, $parentSpan->startChild($context)); } public function handleJobQueuedEvent(JobQueued $event): void { - $this->maybeFinishSpan(); + $this->maybeFinishLocalSpan($event->payload, SpanStatus::ok()); + } + + public function handleJobQueueingFailedEvent(JobQueueingFailed $event): void + { + $this->maybeFinishLocalSpan($event->payload, SpanStatus::internalError()); } public function handleJobProcessedQueueEvent(JobProcessed $event): void @@ -181,6 +196,7 @@ public function handleJobProcessingQueueEvent(JobProcessing $event): void $resolvedJobName = $event->job->resolveName(); $jobPublishedAt = $jobPayload[self::QUEUE_PAYLOAD_PUBLISH_TIME] ?? null; + $jobData = json_encode($jobPayload['data'] ?? []); $job = [ 'messaging.system' => 'hypervel', @@ -190,7 +206,7 @@ public function handleJobProcessingQueueEvent(JobProcessing $event): void 'messaging.message.id' => $jobPayload['uuid'] ?? null, 'messaging.message.envelope.size' => strlen($event->job->getRawBody()), - 'messaging.message.body.size' => strlen(json_encode($jobPayload['data'] ?? [])), + 'messaging.message.body.size' => $jobData === false ? null : strlen($jobData), 'messaging.message.retry.count' => $event->job->attempts() - 1, 'messaging.message.receive.latency' => $jobPublishedAt !== null ? microtime(true) - $jobPublishedAt : null, ]; @@ -230,7 +246,11 @@ public function handleJobFailedEvent(JobFailed $event): void public function handleWorkerStoppingQueueEvent(WorkerStopping $event): void { - Integration::flushEvents(); + if ($event->terminatesImmediately || $event->reason === WorkerStopReason::MaxMemoryExceeded) { + return; + } + + Integration::drainEvents(); } public function handleJobExceptionOccurredQueueEvent(JobExceptionOccurred $event): void diff --git a/src/sentry/src/Jobs/Middleware/SentryTracesSampleRate.php b/src/sentry/src/Jobs/Middleware/SentryTracesSampleRate.php new file mode 100644 index 000000000..efbdd07bd --- /dev/null +++ b/src/sentry/src/Jobs/Middleware/SentryTracesSampleRate.php @@ -0,0 +1,75 @@ + 1.0) { + throw new InvalidArgumentException('Sample rate must be between 0.0 and 1.0.'); + } + } + + /** + * Handle the queued job. + */ + public function handle(object $job, Closure $next): void + { + if (app()->bound(HubInterface::class)) { + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + + if ($transaction !== null && $transaction->getSampled()) { + $transaction->setSampled($this->shouldSample()); + } + } + + $next($job); + } + + /** + * Determine whether the transaction should be sampled. + */ + private function shouldSample(): bool + { + if ($this->sampleRate <= 0.0) { + return false; + } + + if ($this->sampleRate >= 1.0) { + return true; + } + + /* @noinspection RandomApiMigrationInspection */ + return mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax() < $this->sampleRate; + } +} diff --git a/tests/Sentry/Features/QueueIntegrationTest.php b/tests/Sentry/Features/QueueIntegrationTest.php index 8b1e6c33b..0b50bafc6 100644 --- a/tests/Sentry/Features/QueueIntegrationTest.php +++ b/tests/Sentry/Features/QueueIntegrationTest.php @@ -7,12 +7,25 @@ use Exception; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Queue\Events\JobExceptionOccurred; +use Hypervel\Queue\Events\JobProcessed; +use Hypervel\Queue\Events\JobProcessing; +use Hypervel\Queue\Events\JobQueued; +use Hypervel\Queue\Events\JobQueueing; +use Hypervel\Queue\Events\JobQueueingFailed; +use Hypervel\Queue\Events\WorkerStopping; +use Hypervel\Queue\Jobs\SyncJob; +use Hypervel\Queue\SyncQueue; use Hypervel\Sentry\Features\QueueFeature; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Sentry\SentryTestCase; +use RuntimeException; use Sentry\Breadcrumb; use Sentry\EventType; +use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; +use function Hypervel\Coroutine\wait; use function Sentry\addBreadcrumb; use function Sentry\captureException; @@ -35,6 +48,14 @@ protected function withQueueJobTracingDisabled(ApplicationContract $app): void $app['config']->set('sentry.tracing.queue_job_transactions', false); } + protected function withLocalQueueOutputDisabled(ApplicationContract $app): void + { + $app['config']->set('sentry.traces_sample_rate', null); + $app['config']->set('sentry.breadcrumbs.queue_info', false); + $app['config']->set('sentry.tracing.queue_jobs', false); + $app['config']->set('sentry.tracing.queue_job_transactions', false); + } + public function testQueueJobPushesAndPopsScopeWithBreadcrumbs(): void { dispatch(new QueueEventsTestJobWithBreadcrumb); @@ -110,6 +131,28 @@ public function testQueueJobsWithBreadcrumbSetInBetweenKeepsNonJobBreadcrumbsOnC $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); } + #[DefineEnvironment('withLocalQueueOutputDisabled')] + public function testPropagationAndLifecycleFlushRemainWithoutLocalQueueOutput(): void + { + $dispatcher = $this->app->make('events'); + $queue = (new QueueFeatureTestQueue)->setConnectionName('sync'); + $payload = json_decode( + $queue->createPayloadForTest(new QueueEventsTestJob, 'default'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + $this->assertFalse($this->hasQueueFeatureListener(JobQueueing::class)); + $this->assertFalse($this->hasQueueFeatureListener(JobProcessing::class)); + $this->assertTrue($this->hasQueueFeatureListener(JobExceptionOccurred::class)); + $this->assertTrue($this->hasQueueFeatureListener(WorkerStopping::class)); + $this->assertArrayHasKey('sentry_baggage_data', $payload); + $this->assertArrayHasKey('sentry_trace_parent_data', $payload); + $this->assertIsFloat($payload['sentry_publish_time']); + $this->assertArrayNotHasKey('sentry_destination_name', $payload); + } + #[DefineEnvironment('withTracingEnabled')] public function testQueueJobCreatesTransactionByDefault(): void { @@ -127,6 +170,89 @@ public function testQueueJobCreatesTransactionByDefault(): void $this->assertEquals('queue.process', $traceContext['op']); } + #[DefineEnvironment('withTracingEnabled')] + public function testDefaultQueuePublishAndProcessSpansUseResolvedDestination(): void + { + $transaction = $this->startTransaction(); + $queue = (new QueueFeatureTestQueue) + ->setContainer($this->app) + ->setConnectionName('test'); + $payload = $queue->enqueueForTest(new QueueEventsTestJob); + $job = new QueueFeatureResolvedQueueJob($this->app, $payload, 'test', 'default'); + + $this->dispatchHypervelEvent(new JobProcessing('test', $job)); + $this->dispatchHypervelEvent(new JobProcessed('test', $job)); + + $destinations = []; + + foreach ($transaction->getSpanRecorder()->getSpans() as $span) { + if (in_array($span->getOp(), ['queue.publish', 'queue.process'], true)) { + $destinations[$span->getOp()] = $span->getData()['messaging.destination.name']; + } + } + + $this->assertSame([ + 'queue.publish' => 'default', + 'queue.process' => 'default', + ], $destinations); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testPublishSpansMatchMixedOutOfOrderTerminalsByPayload(): void + { + $transaction = $this->startTransaction(); + $payloads = [ + 'A' => json_encode(['uuid' => 'a'], JSON_THROW_ON_ERROR), + 'B' => json_encode(['uuid' => 'b'], JSON_THROW_ON_ERROR), + 'C' => json_encode(['uuid' => 'c'], JSON_THROW_ON_ERROR), + ]; + + foreach ($payloads as $job => $payload) { + $this->dispatchHypervelEvent(new JobQueueing('test', 'default', $job, $payload, null)); + $this->assertSame($transaction, $this->getSentryHubFromContainer()->getSpan()); + } + + $this->dispatchHypervelEvent(new JobQueued('test', 'default', 'b-id', 'B', $payloads['B'], null)); + $this->dispatchHypervelEvent(new JobQueueingFailed( + 'test', + 'default', + 'A', + $payloads['A'], + null, + new RuntimeException('The queue rejected job A.'), + )); + $this->dispatchHypervelEvent(new JobQueued('test', 'default', 'c-id', 'C', $payloads['C'], null)); + + $spans = []; + + foreach (array_slice($transaction->getSpanRecorder()->getSpans(), 1) as $span) { + $spans[$span->getDescription()] = $span; + } + + $this->assertSame(SpanStatus::internalError(), $spans['A']->getStatus()); + $this->assertSame(SpanStatus::ok(), $spans['B']->getStatus()); + $this->assertSame(SpanStatus::ok(), $spans['C']->getStatus()); + $this->assertNotNull($spans['A']->getEndTimestamp()); + $this->assertNotNull($spans['B']->getEndTimestamp()); + $this->assertNotNull($spans['C']->getEndTimestamp()); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testUnterminatedPublishSpanIsFinishedAtCoroutineExit(): void + { + $span = wait(function (): Span { + $transaction = $this->startTransaction(); + $payload = json_encode(['uuid' => 'orphaned'], JSON_THROW_ON_ERROR); + + $this->dispatchHypervelEvent(new JobQueueing('test', 'default', 'Orphaned', $payload, null)); + + return $transaction->getSpanRecorder()->getSpans()[1]; + }); + + $this->assertNotNull($span->getEndTimestamp()); + $this->assertSame(SpanStatus::internalError(), $span->getStatus()); + } + #[DefineEnvironment('withQueueJobTracingDisabled')] public function testQueueJobDoesntCreateTransaction(): void { @@ -136,6 +262,22 @@ public function testQueueJobDoesntCreateTransaction(): void $this->assertNull($transaction); } + + /** + * Determine if the event has a Queue feature listener. + */ + private function hasQueueFeatureListener(string $event): bool + { + $listeners = $this->app->make('events')->getRawListeners()[$event] ?? []; + + foreach ($listeners as $listener) { + if (is_array($listener) && ($listener[0] ?? null) instanceof QueueFeature) { + return true; + } + } + + return false; + } } class QueueEventsTestJob implements ShouldQueue @@ -145,6 +287,42 @@ public function handle(): void } } +class QueueFeatureTestQueue extends SyncQueue +{ + /** + * Enqueue a job through the publication lifecycle for testing. + */ + public function enqueueForTest(object|string $job, ?string $queue = null): string + { + $resolvedQueue = $queue ?? 'default'; + $payload = $this->createPayload($job, $resolvedQueue); + + $this->raiseJobQueueingEvent($queue, $job, $payload, null); + $this->raiseJobQueuedEvent($queue, 'test-id', $job, $payload, null); + + return $payload; + } + + /** + * Create a queue payload for testing. + */ + public function createPayloadForTest(array|object|string $job, ?string $queue): string + { + return $this->createPayload($job, $queue); + } +} + +class QueueFeatureResolvedQueueJob extends SyncJob +{ + /** + * Get the resolved queue name. + */ + public function getQueue(): string + { + return $this->queue; + } +} + function queueEventsTestAddTestBreadcrumb($message = null): void { addBreadcrumb( diff --git a/tests/Sentry/FlushLifecycleTest.php b/tests/Sentry/FlushLifecycleTest.php index 119a9bd14..6febceb64 100644 --- a/tests/Sentry/FlushLifecycleTest.php +++ b/tests/Sentry/FlushLifecycleTest.php @@ -4,88 +4,218 @@ namespace Hypervel\Tests\Sentry; -use Hypervel\Context\CoroutineContext; -use Hypervel\Http\Request; -use Hypervel\Sentry\Transport\HttpPoolTransport; -use Hypervel\Sentry\Transport\Pool; +use Hypervel\Config\Repository; +use Hypervel\Console\Events\CommandFinished; +use Hypervel\Contracts\Container\Container; +use Hypervel\Queue\Events\WorkerStopping; +use Hypervel\Queue\WorkerStopReason; +use Hypervel\Sentry\Features\ConsoleIntegration as ConsoleFeature; +use Hypervel\Sentry\Features\QueueFeature; +use Hypervel\Sentry\Integration; +use Hypervel\Sentry\SdkCapabilities; use Hypervel\Tests\TestCase; use Mockery as m; +use Sentry\ClientInterface; use Sentry\Event; -use Sentry\Transport\HttpTransport; +use Sentry\EventType; +use Sentry\Logs\Logs; +use Sentry\Metrics\TraceMetrics; +use Sentry\Options; +use Sentry\SentrySdk; +use Sentry\State\Hub; +use Sentry\State\HubInterface; +use Sentry\State\Scope; use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; class FlushLifecycleTest extends TestCase { - public function testFlushReleasesAllTransportsCheckedOutDuringRequest(): void + public function testFlushPublishesBufferedTelemetryBeforeFlushingTheTransport(): void { - // Simulate: two events sent during a request, then flush releases both - $httpTransport1 = m::mock(HttpTransport::class); - $httpTransport1->shouldReceive('send') + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') + ->times(3) + ->andReturn(new Options([ + 'enable_logs' => true, + 'enable_metrics' => true, + ])); + $client->shouldReceive('captureEvent') ->once() - ->andReturn(new Result(ResultStatus::success())); - - $httpTransport2 = m::mock(HttpTransport::class); - $httpTransport2->shouldReceive('send') + ->with( + m::on(static fn (Event $event): bool => $event->getType() === EventType::logs()), + null, + m::type(Scope::class), + ) + ->ordered() + ->andReturn(null); + $client->shouldReceive('captureEvent') ->once() + ->with( + m::on(static fn (Event $event): bool => $event->getType() === EventType::metrics()), + null, + m::type(Scope::class), + ) + ->ordered() + ->andReturn(null); + $client->shouldReceive('flush') + ->once() + ->with(null) + ->ordered() ->andReturn(new Result(ResultStatus::success())); - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') - ->twice() - ->andReturn($httpTransport1, $httpTransport2); - $pool->shouldReceive('release') + $this->withHub(new Hub($client), static function (): void { + Logs::getInstance()->info('Buffered log'); + TraceMetrics::getInstance()->count('buffered.metric', 1); + + Integration::flushEvents(); + }); + } + + public function testDrainDerivesAPositiveTimeoutFromTheClient(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') ->once() - ->with($httpTransport1); - $pool->shouldReceive('release') + ->andReturn(new Options(['http_timeout' => 2.2])); + $client->shouldReceive('flush') ->once() - ->with($httpTransport2); + ->with(3) + ->andReturn(new Result(ResultStatus::success())); - $transport = new HttpPoolTransport($pool); + $result = $this->withHub( + new Hub($client), + static fn (): Result => Integration::drainEvents(), + ); - // Simulate sending two events during request handling - $transport->send(Event::createEvent()); - $transport->send(Event::createEvent()); + $this->assertSame(ResultStatus::success(), $result->getStatus()); + } - // Verify transports are tracked in context - $tracked = CoroutineContext::get(HttpPoolTransport::CONTEXT_TRANSPORTS_KEY, []); - $this->assertCount(2, $tracked); + public function testDrainNormalizesAnExplicitNonPositiveTimeout(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') + ->never(); + $client->shouldReceive('flush') + ->once() + ->with(1) + ->andReturn(new Result(ResultStatus::success())); - // Simulate what flush does: client->flush() -> transport->close() - $transport->close(); + $result = $this->withHub( + new Hub($client), + static fn (): Result => Integration::drainEvents(0), + ); - // Verify context is cleaned up - $tracked = CoroutineContext::get(HttpPoolTransport::CONTEXT_TRANSPORTS_KEY, []); - $this->assertCount(0, $tracked); + $this->assertSame(ResultStatus::success(), $result->getStatus()); } - public function testTransportCloseReleasesCheckedOutTransport(): void + public function testDrainWithoutAClientIsAlreadyComplete(): void { - // Verify that close() releases a single checked-out transport back to the pool - $httpTransport = m::mock(HttpTransport::class); - $httpTransport->shouldReceive('send') - ->once() - ->andReturn(new Result(ResultStatus::success())); + $result = $this->withHub( + new Hub, + static fn (): Result => Integration::drainEvents(), + ); + + $this->assertSame(ResultStatus::success(), $result->getStatus()); + } - $pool = m::mock(Pool::class); - $pool->shouldReceive('get') + public function testGracefulQueueWorkerStoppingPerformsABoundedDrain(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') ->once() - ->andReturn($httpTransport); - $pool->shouldReceive('release') + ->andReturn(new Options(['http_timeout' => 1.2])); + $client->shouldReceive('flush') ->once() - ->with($httpTransport); - - $transport = new HttpPoolTransport($pool); - - // Send an event (checks out a transport) - $transport->send(Event::createEvent()); + ->with(2) + ->andReturn(new Result(ResultStatus::success())); + $feature = new QueueFeature(m::mock(Container::class)); + + $this->withHub( + new Hub($client), + static fn () => $feature->handleWorkerStoppingQueueEvent( + new WorkerStopping(reason: WorkerStopReason::QueueEmpty), + ), + ); + } - $this->assertCount(1, CoroutineContext::get(HttpPoolTransport::CONTEXT_TRANSPORTS_KEY, [])); + public function testImmediateAndMemoryLimitQueueStopsDoNotDrain(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions')->never(); + $client->shouldReceive('flush')->never(); + $feature = new QueueFeature(m::mock(Container::class)); + + $this->withHub(new Hub($client), static function () use ($feature): void { + $feature->handleWorkerStoppingQueueEvent(new WorkerStopping(terminatesImmediately: true)); + $feature->handleWorkerStoppingQueueEvent(new WorkerStopping( + reason: WorkerStopReason::MaxMemoryExceeded, + )); + }); + } - // close() releases the transport back to the pool - $transport->close(); + public function testConsoleCompletionPerformsABoundedDrain(): void + { + $client = m::mock(ClientInterface::class); + $client->shouldReceive('getOptions') + ->once() + ->andReturn(new Options(['http_timeout' => 1.2])); + $client->shouldReceive('flush') + ->once() + ->with(2) + ->andReturn(new Result(ResultStatus::success())); + $client->shouldReceive('getIntegration') + ->once() + ->with(Integration::class) + ->andReturn(null); + $config = new Repository([ + 'sentry' => [ + 'dsn' => 'https://public@example.com/1', + 'breadcrumbs' => [ + 'command_info' => false, + ], + ], + ]); + $container = m::mock(Container::class); + $container->shouldReceive('make') + ->once() + ->with('config') + ->andReturn($config); + $container->shouldReceive('make') + ->once() + ->with(SdkCapabilities::class) + ->andReturn(new SdkCapabilities($config)); + $feature = new ConsoleFeature($container); + + $this->withHub(new Hub($client), static function () use ($feature): void { + $feature->commandFinished(new CommandFinished( + 'test:command', + new ArrayInput([]), + new NullOutput, + 0, + )); + }); + } - // All transports should be released - $this->assertCount(0, CoroutineContext::get(HttpPoolTransport::CONTEXT_TRANSPORTS_KEY, [])); + /** + * Run a callback with an isolated SDK Hub. + * + * @template T + * + * @param callable(): T $callback + * + * @return T + */ + private function withHub(HubInterface $hub, callable $callback): mixed + { + $previousHub = SentrySdk::getCurrentHub(); + SentrySdk::setCurrentHub($hub); + + try { + return $callback(); + } finally { + SentrySdk::setCurrentHub($previousHub); + } } } diff --git a/tests/Sentry/Jobs/Middleware/SentryTracesSampleRateTest.php b/tests/Sentry/Jobs/Middleware/SentryTracesSampleRateTest.php new file mode 100644 index 000000000..1a2b4a8c2 --- /dev/null +++ b/tests/Sentry/Jobs/Middleware/SentryTracesSampleRateTest.php @@ -0,0 +1,150 @@ +make('config')->set('sentry.traces_sample_rate', 1.0); + } + + public function testConstructorRejectsNegativeSampleRate(): void + { + $this->expectException(InvalidArgumentException::class); + + new SentryTracesSampleRate(-0.1); + } + + public function testConstructorRejectsSampleRateGreaterThanOne(): void + { + $this->expectException(InvalidArgumentException::class); + + new SentryTracesSampleRate(1.1); + } + + public function testConstructorAcceptsBoundarySampleRates(): void + { + $zero = new SentryTracesSampleRate(0.0); + $one = new SentryTracesSampleRate(1.0); + + $this->assertInstanceOf(SentryTracesSampleRate::class, $zero); + $this->assertInstanceOf(SentryTracesSampleRate::class, $one); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testZeroSampleRateUnsamplesTransaction(): void + { + $transaction = $this->startTransaction(); + + $this->assertTrue($transaction->getSampled()); + + $middleware = new SentryTracesSampleRate(0.0); + $middleware->handle(new stdClass, $this->nextMiddleware()); + + $this->assertFalse($transaction->getSampled()); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testFullSampleRateKeepsTransactionSampled(): void + { + $transaction = $this->startTransaction(); + + $this->assertTrue($transaction->getSampled()); + + $middleware = new SentryTracesSampleRate(1.0); + $middleware->handle(new stdClass, $this->nextMiddleware()); + + $this->assertTrue($transaction->getSampled()); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testDoesNotUpsampleUnsampledTransaction(): void + { + $transaction = $this->startTransaction(); + $transaction->setSampled(false); + + $middleware = new SentryTracesSampleRate(1.0); + $middleware->handle(new stdClass, $this->nextMiddleware()); + + $this->assertFalse($transaction->getSampled()); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testDoesNotUpsampleNullSampledTransaction(): void + { + $transaction = $this->startTransaction(); + $transaction->setSampled(null); + + $middleware = new SentryTracesSampleRate(1.0); + $middleware->handle(new stdClass, $this->nextMiddleware()); + + $this->assertNull($transaction->getSampled()); + } + + public function testMiddlewareCallsNextHandler(): void + { + $called = false; + + $middleware = new SentryTracesSampleRate(1.0); + $middleware->handle(new stdClass, function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testMiddlewareHandlesNoTransaction(): void + { + $this->expectNotToPerformAssertions(); + + $middleware = new SentryTracesSampleRate(0.5); + $middleware->handle(new stdClass, $this->nextMiddleware()); + } + + #[DefineEnvironment('withTracingEnabled')] + public function testPartialSampleRateEventuallyUnsamplesTransaction(): void + { + // With a very low sample rate, running 100 times should produce at least some unsampled results + $unsampledCount = 0; + + for ($i = 0; $i < 100; ++$i) { + $transaction = $this->startTransaction(); + + $middleware = new SentryTracesSampleRate(0.01); + $middleware->handle(new stdClass, $this->nextMiddleware()); + + if (! $transaction->getSampled()) { + ++$unsampledCount; + } + } + + // With a 1% sample rate, we expect most of the 100 runs to be unsampled + $this->assertGreaterThan(80, $unsampledCount); + } + + #[DefineEnvironment('envWithoutDsnSet')] + public function testMiddlewareHandlesHubNotBound(): void + { + $this->expectNotToPerformAssertions(); + + $middleware = new SentryTracesSampleRate(0.5); + $middleware->handle(new stdClass, $this->nextMiddleware()); + } + + private function nextMiddleware(): Closure + { + return static function (): void { + }; + } +} From 4c9717d4b0515a08e6f730729916674b4de459cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:53 +0000 Subject: [PATCH 15/18] Restore current Sentry command and SDK parity Report dynamic Hypervel SDK metadata and profile sampler state, publish environment values through the framework writer with overwrite semantics, and restore process-global error reporting after the Sentry test command. Port scheduled monitor expression overrides and normalize model-violation callables without narrowing supported callback forms.\n\nInitialize the log batch formatter explicitly and cover command output, publication, global-state restoration, schedule configuration, callable objects, and log-channel construction. --- .../src/Console/AboutCommandIntegration.php | 6 +- src/sentry/src/Console/PublishCommand.php | 34 ++------ src/sentry/src/Console/TestCommand.php | 20 +++-- .../src/Features/ConsoleSchedulingFeature.php | 25 ++++-- .../ModelViolationReporter.php | 5 +- src/sentry/src/Logs/LogsHandler.php | 2 +- .../Console/AboutCommandIntegrationTest.php | 15 +++- tests/Sentry/Console/PublishCommandTest.php | 77 +++++++++++++++++++ tests/Sentry/Console/TestCommandTest.php | 29 +++++++ .../ConsoleSchedulingIntegrationTest.php | 43 +++++++++++ .../ModelViolationReportersTest.php | 18 +++++ tests/Sentry/LogChannelTest.php | 9 +++ 12 files changed, 238 insertions(+), 45 deletions(-) create mode 100644 tests/Sentry/Console/PublishCommandTest.php create mode 100644 tests/Sentry/Console/TestCommandTest.php diff --git a/src/sentry/src/Console/AboutCommandIntegration.php b/src/sentry/src/Console/AboutCommandIntegration.php index be3e7a79c..d75d93c0c 100644 --- a/src/sentry/src/Console/AboutCommandIntegration.php +++ b/src/sentry/src/Console/AboutCommandIntegration.php @@ -17,7 +17,7 @@ public function __invoke(HubInterface $hub): array if ($client === null) { return [ 'Enabled' => 'NOT CONFIGURED', - 'Hypervel SDK Version' => Version::SDK_VERSION, + 'Hypervel SDK Version' => Version::getSdkVersion(), 'PHP SDK Version' => Client::SDK_VERSION, ]; } @@ -28,12 +28,12 @@ public function __invoke(HubInterface $hub): array return [ 'Enabled' => $options->getDsn() ? 'YES' : 'MISSING DSN', 'Environment' => $options->getEnvironment() ?: 'NOT SET', - 'Hypervel SDK Version' => Version::SDK_VERSION, + 'Hypervel SDK Version' => Version::getSdkVersion(), 'PHP SDK Version' => Client::SDK_VERSION, 'Release' => $options->getRelease() ?: 'NOT SET', 'Sample Rate Errors' => $this->formatSampleRate($options->getSampleRate()), 'Sample Rate Performance Monitoring' => $this->formatSampleRate($options->getTracesSampleRate(), $options->getTracesSampler() !== null), - 'Sample Rate Profiling' => $this->formatSampleRate($options->getProfilesSampleRate()), + 'Sample Rate Profiling' => $this->formatSampleRate($options->getProfilesSampleRate(), $options->getProfilesSampler() !== null), 'Send Default PII' => $options->shouldSendDefaultPii() ? 'ENABLED' : 'DISABLED', ]; } diff --git a/src/sentry/src/Console/PublishCommand.php b/src/sentry/src/Console/PublishCommand.php index e5c92afa2..eae91768f 100644 --- a/src/sentry/src/Console/PublishCommand.php +++ b/src/sentry/src/Console/PublishCommand.php @@ -7,9 +7,11 @@ use Exception; use Hypervel\Console\Command; use Hypervel\Sentry\SentryServiceProvider; +use Hypervel\Support\Env; use Hypervel\Support\Str; use Sentry\Dsn; use Symfony\Component\Console\Attribute\AsCommand; +use Throwable; #[AsCommand(name: 'sentry:publish')] class PublishCommand extends Command @@ -93,36 +95,16 @@ private function setEnvValues(array $values): bool { $envFilePath = app()->environmentFilePath(); - $envFileContents = file_get_contents($envFilePath); - - if (! $envFileContents) { - $this->error('Could not read `.env` file!'); + try { + Env::writeVariables($values, $envFilePath, overwrite: true); + } catch (Throwable $exception) { + $this->error("Updating the `.env` file failed: {$exception->getMessage()}"); return false; } - if (count($values) > 0) { - foreach ($values as $envKey => $envValue) { - if ($this->isEnvKeySet($envKey, $envFileContents)) { - $envFileContents = preg_replace($this->getEnvKeyPattern($envKey), "{$envKey}={$envValue}\n", $envFileContents); - - $this->info("Updated {$envKey} with new value in your `.env` file."); - } else { - // Ensure there is a newline before writing env variables - if (! str_ends_with($envFileContents, "\n")) { - $envFileContents .= "\n"; - } - $envFileContents .= "{$envKey}={$envValue}\n"; - - $this->info("Added {$envKey} to your `.env` file."); - } - } - } - - if (! file_put_contents($envFilePath, $envFileContents)) { - $this->error('Updating the `.env` file failed!'); - - return false; + foreach (array_keys($values) as $envKey) { + $this->info("Set {$envKey} in your `.env` file."); } return true; diff --git a/src/sentry/src/Console/TestCommand.php b/src/sentry/src/Console/TestCommand.php index 2cfa8159d..b353b51ae 100644 --- a/src/sentry/src/Console/TestCommand.php +++ b/src/sentry/src/Console/TestCommand.php @@ -39,6 +39,18 @@ public function handle(): int { $oldErrorReporting = error_reporting(E_ALL); + try { + return $this->sendTestEvent(); + } finally { + error_reporting($oldErrorReporting); + } + } + + /** + * Send the test event and optional transaction. + */ + private function sendTestEvent(): int + { $dsn = $this->option('dsn'); $configuredClient = null; @@ -121,8 +133,8 @@ public function handle(): int } // Set the Hypervel SDK identifier and version - $clientBuilder->setSdkIdentifier(Version::SDK_IDENTIFIER); - $clientBuilder->setSdkVersion(Version::SDK_VERSION); + $clientBuilder->setSdkIdentifier(Version::getSdkIdentifier()); + $clientBuilder->setSdkVersion(Version::getSdkVersion()); // We set a logger so we can surface errors thrown internally by the SDK $clientBuilder->setLogger(new class($this) extends AbstractLogger { @@ -199,8 +211,6 @@ public function log($level, $message, array $context = []): void $this->info("Transaction sent with ID: {$transactionId}"); } - error_reporting($oldErrorReporting); - return 0; } @@ -243,7 +253,7 @@ private function printDebugTips(): void } elseif (count($this->errorMessages) > 0) { $this->error('Please check the error message from the SDK above for further hints about what went wrong.'); } else { - $this->error('Please check if your DSN is set properly in your `.env` as `SENTRY_DSN` or in your config file `config/sentry.php`.'); + $this->error('Please check if your DSN is set properly in your `.env` as `SENTRY_HYPERVEL_DSN` or `SENTRY_DSN`, or in your config file `config/sentry.php`.'); } } } diff --git a/src/sentry/src/Features/ConsoleSchedulingFeature.php b/src/sentry/src/Features/ConsoleSchedulingFeature.php index b05c42806..3d2d3bdba 100644 --- a/src/sentry/src/Features/ConsoleSchedulingFeature.php +++ b/src/sentry/src/Features/ConsoleSchedulingFeature.php @@ -39,7 +39,8 @@ public function register(): void ?int $maxRuntime, bool $updateMonitorConfig, ?int $failureIssueThreshold, - ?int $recoveryThreshold + ?int $recoveryThreshold, + ?string $schedule ) { $this->startCheckIn( $slug, @@ -48,7 +49,8 @@ public function register(): void $maxRuntime, $updateMonitorConfig, $failureIssueThreshold, - $recoveryThreshold + $recoveryThreshold, + $schedule ); }; $finishCheckIn = function (?string $slug, SchedulingEvent $scheduled, CheckInStatus $status) { @@ -61,7 +63,8 @@ public function register(): void ?int $maxRuntime = null, bool $updateMonitorConfig = true, ?int $failureIssueThreshold = null, - ?int $recoveryThreshold = null + ?int $recoveryThreshold = null, + ?string $schedule = null ) use ($startCheckIn, $finishCheckIn) { /** @var SchedulingEvent $this */ if ($monitorSlug === null && empty($this->command) && empty($this->description)) { @@ -78,7 +81,8 @@ public function register(): void $maxRuntime, $updateMonitorConfig, $failureIssueThreshold, - $recoveryThreshold + $recoveryThreshold, + $schedule ) { /** @var SchedulingEvent $this */ $startCheckIn( @@ -88,7 +92,8 @@ public function register(): void $maxRuntime, $updateMonitorConfig, $failureIssueThreshold, - $recoveryThreshold + $recoveryThreshold, + $schedule ); }) ->onSuccess(function () use ($finishCheckIn, $monitorSlug) { @@ -110,6 +115,11 @@ public function isApplicable(): bool public function onBoot(): void { $this->shouldHandleCheckIn = true; + + if (! $this->canRecordSpans()) { + return; + } + $dispatcher = $this->container->make('events'); $dispatcher->listen(ScheduledTaskStarting::class, [$this, 'handleScheduledTaskStarting']); @@ -175,7 +185,8 @@ private function startCheckIn( ?int $maxRuntime, bool $updateMonitorConfig, ?int $failureIssueThreshold, - ?int $recoveryThreshold + ?int $recoveryThreshold, + ?string $schedule ): void { if (! $this->shouldHandleCheckIn) { return; @@ -194,7 +205,7 @@ private function startCheckIn( $checkIn->setMonitorConfig( new MonitorConfig( - MonitorSchedule::crontab($scheduled->getExpression()), + MonitorSchedule::crontab($schedule ?? $scheduled->getExpression()), $checkInMargin, $maxRuntime, $timezone, diff --git a/src/sentry/src/Integration/ModelViolations/ModelViolationReporter.php b/src/sentry/src/Integration/ModelViolations/ModelViolationReporter.php index 00c6d96f5..101ed09a1 100644 --- a/src/sentry/src/Integration/ModelViolations/ModelViolationReporter.php +++ b/src/sentry/src/Integration/ModelViolations/ModelViolationReporter.php @@ -24,11 +24,14 @@ abstract class ModelViolationReporter private const CONTEXT_REPORTED_PREFIX = '__sentry.model_violations.reported.'; + private ?Closure $callback; + public function __construct( - private ?Closure $callback, + ?callable $callback, private readonly bool $suppressDuplicateReports, private readonly bool $reportAfterResponse, ) { + $this->callback = $callback === null ? null : Closure::fromCallable($callback); } /** diff --git a/src/sentry/src/Logs/LogsHandler.php b/src/sentry/src/Logs/LogsHandler.php index 495bf724c..404dceb5b 100644 --- a/src/sentry/src/Logs/LogsHandler.php +++ b/src/sentry/src/Logs/LogsHandler.php @@ -19,7 +19,7 @@ class LogsHandler extends AbstractProcessingHandler /** * The formatter to use for the logs generated via handleBatch(). */ - protected ?FormatterInterface $batchFormatter; + protected ?FormatterInterface $batchFormatter = null; public function handleBatch(array $records): void { diff --git a/tests/Sentry/Console/AboutCommandIntegrationTest.php b/tests/Sentry/Console/AboutCommandIntegrationTest.php index d0d929c31..a3c40c507 100644 --- a/tests/Sentry/Console/AboutCommandIntegrationTest.php +++ b/tests/Sentry/Console/AboutCommandIntegrationTest.php @@ -30,7 +30,7 @@ public function testAboutCommandContainsExpectedData(): void 'sample_rate_performance_monitoring' => '95%', 'send_default_pii' => 'DISABLED', 'php_sdk_version' => Client::SDK_VERSION, - 'hypervel_sdk_version' => Version::SDK_VERSION, + 'hypervel_sdk_version' => Version::getSdkVersion(), ]; $actualData = $this->runArtisanAboutAndReturnSentryData(); @@ -41,6 +41,17 @@ public function testAboutCommandContainsExpectedData(): void } } + public function testAboutCommandRecognizesCustomProfileSampler(): void + { + $this->resetApplicationWithConfig([ + 'sentry.profiles_sampler' => static fn (): float => 1.0, + ]); + + $actualData = $this->runArtisanAboutAndReturnSentryData(); + + $this->assertSame('CUSTOM SAMPLER', $actualData['sample_rate_profiling']); + } + public function testAboutCommandContainsExpectedDataWithoutHubClient(): void { $this->app->bind(HubInterface::class, static function () { @@ -50,7 +61,7 @@ public function testAboutCommandContainsExpectedDataWithoutHubClient(): void $expectedData = [ 'enabled' => 'NOT CONFIGURED', 'php_sdk_version' => Client::SDK_VERSION, - 'hypervel_sdk_version' => Version::SDK_VERSION, + 'hypervel_sdk_version' => Version::getSdkVersion(), ]; $actualData = $this->runArtisanAboutAndReturnSentryData(); diff --git a/tests/Sentry/Console/PublishCommandTest.php b/tests/Sentry/Console/PublishCommandTest.php new file mode 100644 index 000000000..d32aea246 --- /dev/null +++ b/tests/Sentry/Console/PublishCommandTest.php @@ -0,0 +1,77 @@ +assertFalse((bool) $isEnvKeySetMethod->invoke( + $command, + 'SENTRY.*KEY', + "SENTRYAKEY=true\n" + )); + + $this->assertTrue((bool) $isEnvKeySetMethod->invoke( + $command, + 'SENTRY.*KEY', + "SENTRY.*KEY=true\n" + )); + } + + public function testEnvKeyPatternEscapesRegexMetacharactersForReplacement(): void + { + $command = new PublishCommand; + + $getEnvKeyPatternMethod = new ReflectionMethod($command, 'getEnvKeyPattern'); + $pattern = $getEnvKeyPatternMethod->invoke($command, 'SENTRY.*KEY'); + + $updatedContents = preg_replace( + $pattern, + "SENTRY.*KEY=new\n", + "SENTRYAKEY=old\nSENTRY.*KEY=old\n" + ); + + $this->assertSame("SENTRYAKEY=old\nSENTRY.*KEY=new\n", $updatedContents); + } + + public function testSetEnvValuesOverwritesExistingVariablesThroughTheSharedWriter(): void + { + $directory = ParallelTesting::tempDir('SentryPublishCommandTest'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($directory); + $filesystem->makeDirectory($directory); + $filesystem->put("{$directory}/.env", "SENTRY_HYPERVEL_DSN=old\n"); + + $originalEnvironmentPath = $this->app->environmentPath(); + $this->app->useEnvironmentPath($directory); + + try { + $command = new PublishCommand; + $method = new ReflectionMethod($command, 'setEnvValues'); + + $this->assertTrue($method->invoke($command, [ + 'SENTRY_HYPERVEL_DSN' => 'https://public@sentry.test/1', + 'SENTRY_SEND_DEFAULT_PII' => 'true', + ])); + $this->assertSame( + "SENTRY_HYPERVEL_DSN=\"https://public@sentry.test/1\"\nSENTRY_SEND_DEFAULT_PII=true\n", + $filesystem->get("{$directory}/.env") + ); + } finally { + $this->app->useEnvironmentPath($originalEnvironmentPath); + $filesystem->deleteDirectory($directory); + } + } +} diff --git a/tests/Sentry/Console/TestCommandTest.php b/tests/Sentry/Console/TestCommandTest.php new file mode 100644 index 000000000..9fd3569f7 --- /dev/null +++ b/tests/Sentry/Console/TestCommandTest.php @@ -0,0 +1,29 @@ + null, + ]; + + public function testErrorReportingIsRestoredWhenTheCommandFails(): void + { + $initialErrorReporting = error_reporting(E_ERROR); + + try { + $this->artisan('sentry:test') + ->expectsOutputToContain('SENTRY_HYPERVEL_DSN') + ->assertExitCode(1); + + $this->assertSame(E_ERROR, error_reporting()); + } finally { + error_reporting($initialErrorReporting); + } + } +} diff --git a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php index abd43ca18..9b358a6c4 100644 --- a/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php +++ b/tests/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -6,6 +6,7 @@ use DateTimeZone; use Hypervel\Bus\Queueable; +use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Console\Scheduling\Event; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Queue\ShouldQueue; @@ -75,6 +76,21 @@ public function testScheduleMacroWithTimeZone(): void $this->assertEquals($expectedTimezone, $finishCheckInEvent->getCheckIn()->getMonitorConfig()->getTimezone()); } + public function testScheduleMacroCanOverrideTheMonitorExpression(): void + { + $scheduledEvent = $this->getScheduler() + ->call(static function (): void { + }) + ->daily() + ->sentryMonitor('custom-schedule-monitor', schedule: '*/5 * * * *'); + + $scheduledEvent->run($this->app); + + $monitorConfig = $this->getLastSentryEvent()->getCheckIn()->getMonitorConfig(); + + $this->assertSame('*/5 * * * *', $monitorConfig->getSchedule()->getValue()); + } + public function testScheduleMacroAutomaticSlugForCommand(): void { /** @var Event $scheduledEvent */ @@ -148,6 +164,33 @@ public function testScheduleMacroIsRegistered(): void $this->assertTrue(Event::hasMacro('sentryMonitor')); } + public function testMonitorCheckInsRemainWithoutScheduledTracingListeners(): void + { + $this->resetApplicationWithConfig([ + 'sentry.traces_sample_rate' => null, + 'sentry.features' => [ + ConsoleSchedulingFeature::class, + ], + ]); + $listeners = $this->app->make('events')->getRawListeners()[ScheduledTaskStarting::class] ?? []; + + foreach ($listeners as $listener) { + $this->assertFalse( + is_array($listener) && ($listener[0] ?? null) instanceof ConsoleSchedulingFeature, + ); + } + + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler() + ->call(static function (): void { + }) + ->sentryMonitor('check-ins-without-tracing'); + + $scheduledEvent->run($this->app); + + $this->assertSentryCheckInCount(2); + } + public function testScheduleMacroIsRegisteredWithoutDsnSet(): void { $this->resetApplicationWithConfig([ diff --git a/tests/Sentry/Integration/ModelViolationReportersTest.php b/tests/Sentry/Integration/ModelViolationReportersTest.php index 64657fecc..48a835709 100644 --- a/tests/Sentry/Integration/ModelViolationReportersTest.php +++ b/tests/Sentry/Integration/ModelViolationReportersTest.php @@ -66,6 +66,24 @@ public function testViolationReporterPassesThroughToCallback(): void $this->assertTrue($callbackCalled); } + public function testViolationReporterNormalizesNonClosureCallables(): void + { + $callback = new class { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $reporter = Integration::missingAttributeViolationReporter($callback, false, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertTrue($callback->called); + } + public function testViolationReporterIsNotReportingDuplicateEvents(): void { $reporter = Integration::missingAttributeViolationReporter(null, true, false); diff --git a/tests/Sentry/LogChannelTest.php b/tests/Sentry/LogChannelTest.php index aab9bbc09..fd9b98e65 100644 --- a/tests/Sentry/LogChannelTest.php +++ b/tests/Sentry/LogChannelTest.php @@ -9,6 +9,8 @@ use Hypervel\Sentry\Logs\LogChannel as LogsLogChannel; use Hypervel\Sentry\Logs\LogsHandler; use Hypervel\Sentry\SentryHandler; +use Monolog\Formatter\LineFormatter; +use Monolog\Logger; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; use Sentry\Event; @@ -65,6 +67,13 @@ public function testCreatingLogsHandlerWithActionLevelConfig(): void $this->assertInstanceOf(LogsHandler::class, $currentHandler->getHandler()); } + public function testLogsHandlerCreatesItsDefaultBatchFormatter(): void + { + $handler = new LogsHandler(Logger::DEBUG); + + $this->assertInstanceOf(LineFormatter::class, $handler->getBatchFormatter()); + } + #[DataProvider('handlerDataProvider')] public function testHandlerWritingExpectedEventsAndContext(array $context, callable $asserter): void { From 34cce004ccc2457b97fd3f11f1eea8fbe71c13f5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:02:01 +0000 Subject: [PATCH 16/18] Document Sentry operations and complete split metadata Declare the direct runtime dependencies used by the distributed Sentry package and raise the SDK floor only to the version required for profiles sampler support. Keep optional Sanctum interoperability out of requirements and suggestions because installing it unlocks no Sentry capability.\n\nAdd Laravel-style guidance for setup, DSN precedence, tracing, metrics, queues, monitors, storage, PII, Spotlight, pooled transport backpressure, and bounded shutdown. Keep the package README limited to documentation, its intentional asynchronous transport difference, and upstream provenance. --- composer.json | 2 +- src/boost/docs/documentation.md | 1 + src/boost/docs/sentry.md | 329 ++++++++++++++++++++++++++++++++ src/sentry/README.md | 11 +- src/sentry/composer.json | 12 +- 5 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/boost/docs/sentry.md diff --git a/composer.json b/composer.json index 207134a94..97697097c 100644 --- a/composer.json +++ b/composer.json @@ -188,7 +188,7 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "psy/psysh": "^0.12.22", - "sentry/sentry": "^4.15", + "sentry/sentry": "^4.27", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1", "symfony/error-handler": "^8.1", diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md index dd6ae594b..399847c49 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -103,6 +103,7 @@ - [Reverb](/docs/{{version}}/reverb) - [Sanctum](/docs/{{version}}/sanctum) - [Scout](/docs/{{version}}/scout) + - [Sentry](/docs/{{version}}/sentry) - [Socialite](/docs/{{version}}/socialite) - [Telescope](/docs/{{version}}/telescope) - [Testbench](/docs/{{version}}/testbench) diff --git a/src/boost/docs/sentry.md b/src/boost/docs/sentry.md new file mode 100644 index 000000000..1d1922cdf --- /dev/null +++ b/src/boost/docs/sentry.md @@ -0,0 +1,329 @@ +# Hypervel Sentry + +- [Introduction](#introduction) +- [Installation](#installation) + - [Configuration](#configuration) + - [Testing Your Installation](#testing-your-installation) +- [Reporting Exceptions](#reporting-exceptions) +- [Logging](#logging) + - [Sentry Events](#sentry-events) + - [Sentry Logs](#sentry-logs) +- [Performance Monitoring](#performance-monitoring) + - [Sampling](#sampling) + - [Queued Jobs](#queued-jobs) + - [Metrics](#metrics) + - [Scheduled Tasks](#scheduled-tasks) +- [Filesystem Monitoring](#filesystem-monitoring) +- [Sensitive Data](#sensitive-data) +- [Spotlight](#spotlight) +- [Delivery and Shutdown](#delivery-and-shutdown) + + +## Introduction + +[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. + + +## Installation + +You may install the Sentry integration using the Composer package manager: + +```shell +composer require hypervel/sentry +``` + +Next, register Sentry with Hypervel's exception handler in your application's `bootstrap/app.php` file: + +```php +use Hypervel\Foundation\Configuration\Exceptions; +use Hypervel\Sentry\Integration; + +->withExceptions(function (Exceptions $exceptions): void { + Integration::handles($exceptions); +}) +``` + +Finally, run the `sentry:publish` Artisan command. This command publishes the Sentry configuration file, stores your DSN in the application's `.env` file, and can send a test event: + +```shell +php artisan sentry:publish --dsn=https://examplePublicKey@o0.ingest.sentry.io/0 +``` + + +### Configuration + +After publishing Sentry's configuration, its primary configuration file will be located at `config/sentry.php`. Each option includes a description of its purpose. + +Sentry first reads the `SENTRY_HYPERVEL_DSN` environment variable. If it is not set, the generic `SENTRY_DSN` variable is used instead: + +```ini +SENTRY_HYPERVEL_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 +``` + +You may leave the DSN unset to disable event delivery while keeping the package installed, unless Spotlight is enabled. + + +### Testing Your Installation + +You may send a test event using the `sentry:test` Artisan command: + +```shell +php artisan sentry:test +``` + +To test both error reporting and performance monitoring, include the `transaction` option: + +```shell +php artisan sentry:test --transaction +``` + + +## Reporting Exceptions + +Once Sentry is registered with Hypervel's exception handler, reportable exceptions are captured automatically. You may also report an exception manually using Sentry's `captureException` function: + +```php +use Throwable; + +use function Sentry\captureException; + +try { + // ... +} catch (Throwable $exception) { + captureException($exception); +} +``` + +Hypervel's normal exception filtering still applies. For example, validation exceptions are ignored by the default Sentry configuration. + + +## Logging + +Hypervel registers two Sentry log channels. The `sentry` channel creates Sentry events, while the `sentry_logs` channel sends records to Sentry's structured Logs product. + + +### Sentry Events + +You may add the `sentry` channel to a log stack in your application's `config/logging.php` file: + +```php +'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'sentry'], + ], +], +``` + +Log records sent through this channel are converted into Sentry events. Exceptions included in the log context retain their exception details and stack trace. + + +### Sentry Logs + +To use Sentry Logs, enable the feature in your `.env` file: + +```ini +SENTRY_ENABLE_LOGS=true +``` + +You may then write to the automatically registered `sentry_logs` channel: + +```php +use Hypervel\Support\Facades\Log; + +Log::channel('sentry_logs')->info('Order shipped', [ + 'order_id' => $order->id, +]); +``` + +The channel uses `SENTRY_LOG_LEVEL`, falling back to `SENTRY_LOGS_LEVEL` and then your application's `LOG_LEVEL` value. + + +## Performance Monitoring + +To enable performance monitoring, configure a trace sample rate between `0.0` and `1.0`: + +```ini +SENTRY_TRACES_SAMPLE_RATE=0.1 +``` + +The default configuration traces requests, database queries, HTTP client requests, cache operations, queued jobs, notifications, and views. The conventional `/up` health route path is ignored by default. + +Incoming trace headers are still propagated when local trace recording is disabled. This allows a Hypervel service to remain part of a distributed trace without recording its own transaction. + +By default, request transactions include work performed after the response is sent. You may finish transactions during the HTTP terminate phase instead: + +```ini +SENTRY_TRACE_CONTINUE_AFTER_RESPONSE=false +``` + + +### Sampling + +The `traces_sample_rate` option provides a fixed sample rate. For application-specific decisions, you may define a `traces_sampler` callback in `config/sentry.php`: + +```php +use Sentry\Tracing\SamplingContext; + +'traces_sampler' => function (SamplingContext $context): float { + $transaction = $context->getTransactionContext(); + + return $transaction !== null && str_starts_with($transaction->getName(), 'admin.') + ? 1.0 + : 0.1; +}, +``` + +Profiling may be configured with `SENTRY_PROFILES_SAMPLE_RATE` or a `profiles_sampler` callback. Profiles are only collected for sampled transactions. + + +### Queued Jobs + +Trace context is propagated through queued jobs automatically. If a particular job should use a lower sample rate than the rest of your application, add the `SentryTracesSampleRate` middleware to the job: + +```php +use Hypervel\Sentry\Jobs\Middleware\SentryTracesSampleRate; + +/** + * Get the middleware the job should pass through. + */ +public function middleware(): array +{ + return [new SentryTracesSampleRate(0.1)]; +} +``` + +This middleware can downsample a transaction that was already sampled by your global configuration. It does not force an unsampled transaction to be recorded. + + +### Metrics + +Sentry trace metrics are enabled by default. You may record counters, distributions, and gauges using the Sentry SDK: + +```php +use Sentry\Unit; + +use function Sentry\traceMetrics; + +traceMetrics()->count('orders.created', 1, [ + 'region' => 'eu', +]); + +traceMetrics()->distribution('request.duration', 125, unit: Unit::millisecond()); + +traceMetrics()->gauge('queue.depth', 42); +``` + +You may disable metrics using the `SENTRY_ENABLE_METRICS` environment variable: + +```ini +SENTRY_ENABLE_METRICS=false +``` + + +### Scheduled Tasks + +You may monitor a scheduled task using the `sentryMonitor` macro: + +```php +use Hypervel\Support\Facades\Schedule; + +Schedule::command('reports:generate') + ->daily() + ->sentryMonitor('daily-reports'); +``` + +The task's schedule is detected automatically. You may provide a different cron expression when the monitor should use a schedule that cannot be derived from the event: + +```php +Schedule::command('reports:generate') + ->sentryMonitor('daily-reports', schedule: '0 2 * * *'); +``` + + +## Filesystem Monitoring + +Sentry can record filesystem operations as spans and breadcrumbs. Wrap one disk using `StorageIntegration::configureDisk`, or wrap every configured disk using `configureDisks`: + +```php +use Hypervel\Sentry\Features\Storage\Integration as StorageIntegration; + +'disks' => StorageIntegration::configureDisks([ + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + ], + + 's3' => [ + 'driver' => 's3', + // ... + ], +]), +``` + +To configure a single disk, pass its name and configuration: + +```php +'archive' => StorageIntegration::configureDisk('archive', [ + 'driver' => 's3', + // ... +]), +``` + +Both methods accept `enableSpans` and `enableBreadcrumbs` arguments. Per-disk settings cannot enable telemetry that is disabled globally. + +The integration preserves filesystem pooling, scoped prefixes, temporary URLs, streaming behavior, and fluent filesystem operations. + + +## Sensitive Data + +Sentry does not include personally identifiable information by default. You may allow it using the `SENTRY_SEND_DEFAULT_PII` environment variable: + +```ini +SENTRY_SEND_DEFAULT_PII=true +``` + +Redis command parameters are omitted unless this option is enabled. When enabled, the active session key is still redacted from Redis and cache telemetry. + +SQL bindings are controlled separately using `SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED` and `SENTRY_TRACE_SQL_BINDINGS_ENABLED`. Review these settings carefully before enabling them in production. + + +## Spotlight + +[Sentry Spotlight](https://spotlightjs.com/) displays Sentry telemetry locally while you develop your application. You may enable its default endpoint using `true`, or provide a custom Spotlight URL: + +```ini +SENTRY_SPOTLIGHT=true +``` + +```ini +SENTRY_SPOTLIGHT=http://localhost:8969/stream +``` + +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. If the pool is exhausted during an exception storm, new telemetry is dropped instead of delaying application work. + +Console commands and graceful queue-worker shutdowns perform a bounded drain. Delivery during a worker exit is best effort because Swoole may terminate outstanding reactor work after its shutdown deadline. + +The Sentry HTTP timeout and Swoole's worker shutdown timeout are configured independently. Your server's `server.settings.max_wait_time` value should be greater than `SENTRY_HTTP_TIMEOUT`, with enough additional time for other shutdown work: + +```ini +SENTRY_HTTP_TIMEOUT=2 +``` + +```php +// config/server.php +use Swoole\Constant; + +'settings' => [ + Constant::OPTION_MAX_WAIT_TIME => 3, +], +``` + +Increasing these values does not change normal request latency. They only bound transport operations and graceful shutdown work. diff --git a/src/sentry/README.md b/src/sentry/README.md index 3b2f686f1..91251bf74 100644 --- a/src/sentry/README.md +++ b/src/sentry/README.md @@ -1,4 +1,9 @@ -Sentry for Hypervel -=== +# Sentry for Hypervel -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/sentry) \ No newline at end of file +Documentation: https://hypervel.org/docs/sentry + +## Differences From Laravel + +- Events are sent asynchronously through a bounded, reusable transport pool. Pool exhaustion drops telemetry instead of blocking application work, and worker-exit delivery is best effort. + +Ported from: https://github.com/getsentry/sentry-laravel diff --git a/src/sentry/composer.json b/src/sentry/composer.json index f4ed12452..1e25c3922 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -30,7 +30,7 @@ }, "require": { "php": "^8.4", - "sentry/sentry": "^4.15", + "guzzlehttp/guzzle": "^7.15.1", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", @@ -42,6 +42,7 @@ "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/di": "^0.4", + "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/log": "^0.4", @@ -51,9 +52,16 @@ "hypervel/redis": "^0.4", "hypervel/routing": "^0.4", "hypervel/support": "^0.4", + "hypervel/validation": "^0.4", "hypervel/view": "^0.4", + "monolog/monolog": "^3.1", + "nyholm/psr7": "^1.0", + "psr/http-message": "^2.0", + "psr/log": "^3.0", + "sentry/sentry": "^4.27", "symfony/console": "^8.1", - "symfony/http-foundation": "^8.1" + "symfony/http-foundation": "^8.1", + "symfony/psr-http-message-bridge": "^8.1" }, "config": { "sort-packages": true From 34cea1d4624be43f11b96198ccb8a3390e569333 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:02:09 +0000 Subject: [PATCH 17/18] Require functional Composer metadata Clarify that ext requirements belong only to extensions not guaranteed by Hypervel's minimum PHP version. Limit suggest entries to installable packages that unlock a concrete documented capability, excluding incidental interoperability, class strings, tests, and metadata symmetry.\n\nThis prevents redundant core-extension requirements and misleading optional-package suggestions from being added for completeness alone. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index bc56a9dad..7b027d901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ The Working rules and the Avoid overengineering rules apply to all work in this - **Treat past owner decisions as context, not constraints** — Previous owner approvals and completed plans explain history but do not determine the best design today. Never retain or reject a design merely because it was previously approved; decide from current requirements, code, and evidence. - **Revert failed attempts immediately** — when a fix doesn't work, revert it before trying another approach. Don't leave experimental code in place. - **Check dependency versions before adding them** — Before adding a package dependency to the root `composer.json`, check Packagist for the latest compatible stable version. The root `composer.lock` is intentionally untracked; run `composer update` after adding or merging dependency changes, do not treat an outdated local lock as a repository defect, and never commit it. +- **Keep Composer metadata functional** — Declare an `ext-*` requirement only when the extension is not guaranteed by Hypervel's minimum PHP version. Add a `suggest` entry only when installing that package enables a concrete, documented feature; conditional interoperability, class-string references, tests, or metadata completeness do not qualify. ### Documentation From 46d8aa8bf337511b733b6f3d156083f974b8ff0d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:02:23 +0000 Subject: [PATCH 18/18] Record the completed Sentry performance audit Add the signed-off implementation plan with the verified Swoole lifecycle facts, exact ownership model, accepted and rejected designs, testing strategy, performance constraints, and compatibility result.\n\nClose Sentry in the core package checklist and update the audit ledger with every durable Sentry and shared-owner finding, dependency edge, validation gate, final review amendment, and the conclusion that no accepted defect or deferred implementation remains. --- ...amework-coroutine-state-lifecycle-audit.md | 27 +- ...-coroutine-state-lifecycle-audit-ledger.md | 65 +- ...ess-coroutine-ownership-and-performance.md | 753 ++++++++++++++++++ 3 files changed, 833 insertions(+), 12 deletions(-) create mode 100644 docs/plans/2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 2ebfae997..77e5c896a 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `permission`; targeted correctness, custom-pivot, relation-scope, and metadata maintenance is recorded under `Complete Permission correctness, custom pivots, and relation scope safety`; detail plan `2026-08-08-0059-permission-correctness-extension-parity-and-relation-scope-safety.md`. The later fresh Permission audit remains open. -- **Ledger entries required for the active work:** `Complete Permission correctness, custom pivots, and relation scope safety`. -- **Pending revalidation carried into the active work:** None. Permission revalidated `support-02`; Database and every corrected metadata sibling were revalidated in the same work unit. +- **Active package or work unit:** `sentry`; the complete correctness, coroutine-ownership, parity, and performance audit is recorded under `Complete Sentry correctness, coroutine ownership, and performance`; detail plan `2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md`. +- **Ledger entries required for the active work:** `Complete Sentry correctness, coroutine ownership, and performance`; `Make coroutine creation and copied context failure-safe`; `Harden Core lifecycle callbacks and stdout logging`; `Isolate object-pool maintenance and remove false dependencies`; `Harden filesystem I/O, streaming, and response teardown`; `Complete Cache parity, cleanup, permanence, and tagged ownership`; `Complete Notifications correctness, Slack parity, and reentrant failure ownership`; `Complete Queue pooling, payload durability, and current Laravel parity`; `Correct AOP proxy generation and publication`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; and `Harden Server startup, reload, and process lifecycles`. +- **Pending revalidation carried into the active work:** Telescope must retain captured fork values under `coroutine-08`; Sentry and every other named consumer are revalidated in this work unit. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1038,7 +1038,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `pool-08` | `pool`, `redis` | `redis` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `pool-08` | | `database-01` | `database` | `database` (revalidation complete) | `Release cleared coordinator timers deterministically`; finding `database-01` | | `redis-01` | `redis` | `redis` (revalidation complete) | `Release cleared coordinator timers deterministically`; finding `redis-01` | -| `di-02` | `di` | `foundation` (revalidation complete); later full `sentry` and `telescope` audits | `Correct AOP proxy generation and publication`; finding `di-02` | +| `di-02` | `di` | `foundation` and `sentry` (revalidation complete); later full `telescope` audit | `Correct AOP proxy generation and publication`; finding `di-02` | | `filesystem-02` | `filesystem` | `di` and `filesystem` (revalidation complete) | `Correct AOP proxy generation and publication`; finding `filesystem-02` | | `filesystem-03` | `filesystem` | `encryption`, `support`, and `filesystem` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` | | `filesystem-04` | `filesystem` | `cache` (revalidation complete) | `Harden filesystem I/O, streaming, and response teardown`; finding `filesystem-04` | @@ -1105,11 +1105,11 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `redis-21` | `redis` | `queue` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-21` | | `redis-22` | `redis` | `queue` and `support` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-22` | | `reverb-05` | `reverb` | `redis` and `reverb` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `reverb-05` | -| `redis-15` | `redis` | `telescope` and `sentry` (revalidation complete); later full consumer audits | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-15` | +| `redis-15` | `redis` | `sentry` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-15` | | `horizon-01` | `horizon` | `redis` and `horizon` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `horizon-01` | | `telescope-01` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-01` | | `telescope-02` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-02` | -| `sentry-01` | `sentry` | `redis` (revalidation complete); later full `sentry` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` | +| `sentry-01` | `sentry` | `redis` and `sentry` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` | | `cache-04` | `cache` | `auth`, `sanctum`, and `testbench` (revalidation complete); later full remaining consumer audits | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-04` | | `filesystem-12` | `filesystem` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `filesystem-12` | | `session-23` | `cache` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `session-23` | @@ -1246,6 +1246,19 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `reverb-40` | `reverb` | `reverb` (targeted correction complete) | `Complete Socialite correctness, first-party extensibility, and lifecycle`; finding `reverb-40` | | `queue-42` | `queue` | `queue` (targeted correction complete) | `Complete Prompts correctness, current parity, and terminal lifecycles`; finding `queue-42` | | `testbench-04` | `testbench` | `testbench` (targeted correction complete) and `prompts` (revalidation complete); later full `testbench` audit | `Complete Prompts correctness, current parity, and terminal lifecycles`; finding `testbench-04` | +| `coroutine-08` | `coroutine` | `sentry` (revalidation complete); later full `telescope` audit | `Make coroutine creation and copied context failure-safe`; finding `coroutine-08` | +| `core-09` | `core` | `sentry` (revalidation complete), `server` (unchanged callback wiring revalidated) | `Harden Core lifecycle callbacks and stdout logging`; finding `core-09` | +| `object-pool-05` | `object-pool` | `filesystem` and `sentry` (revalidation complete) | `Isolate object-pool maintenance and remove false dependencies`; finding `object-pool-05` | +| `object-pool-06` | `object-pool` | `filesystem` and `sentry` (revalidation complete) | `Isolate object-pool maintenance and remove false dependencies`; finding `object-pool-06` | +| `filesystem-15` | `filesystem` | `object-pool` and `sentry` (revalidation complete) | `Harden filesystem I/O, streaming, and response teardown`; finding `filesystem-15` | +| `filesystem-16` | `filesystem` | `sentry` (revalidation complete) | `Harden filesystem I/O, streaming, and response teardown`; finding `filesystem-16` | +| `cache-21` | `cache` | `sentry` (revalidation complete) | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-21` | +| `cache-22` | `cache` | `sentry` (revalidation complete) | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-22` | +| `notifications-22` | `notifications` | `sentry` (revalidation complete) | `Complete Notifications correctness, Slack parity, and reentrant failure ownership`; finding `notifications-22` | +| `notifications-23` | `notifications` | `sentry` (revalidation complete) | `Complete Notifications correctness, Slack parity, and reentrant failure ownership`; finding `notifications-23` | +| `queue-43` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-43` | +| `queue-44` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-44` | +| `queue-45` | `queue` | `sentry` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-45` | ## Package checklist @@ -1345,7 +1358,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [ ] `jwt` - [x] `scout` - [ ] `telescope` -- [ ] `sentry` +- [x] `sentry` - [ ] `inertia` - [x] `nested-set` - [x] `json-schema` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index b8135545a..fec8cbe1f 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -390,13 +390,14 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved implementation boundary:** The owner approved replacing the inherited Hyperf closure generator with the wrapper/helper design after reviewing the reproduced failures, upstream-maintenance cost, and overengineering boundary. The owner also approved `filesystem-02`, including the Laravel-facing change from silent directory-creation failure to a deterministic exception. Laravel has no native AOP API; Hypervel's documented aspect registration, target declarations, `ProceedingJoinPoint`, advice pipeline, and configuration remain unchanged. The complete intercepted hot path must be benchmarked, and any measured regression returns to the owner stop gate before implementation continues. - **Generation and runtime design:** Preserve original method signatures, attributes, documentation, modifiers, defaults, return types, lexical magic constants, PHP 8.4 closure descriptors, and top-level argument-introspection behavior. Resolve direct argument-introspection calls through PHP's function-import and runtime namespace-fallback rules; reject literal indirect `call_user_func` forms and unpacked argument-introspection calls that PHP can bind to the caller frame rather than silently reporting the helper frame. Forward reference parameters and variadics by reference so advice and the original body mutate the caller's values. Use expression dispatch for `void` and `never`. Compute the complete aspect list locally and publish it to `AspectManager` once. Inject an intentionally empty `ProxyMarker` trait and detect its exact identity through the existing recursive trait helper, including proxied traits and inherited proxies, without reserving a target method name. Leave unstable anonymous-class runtime identities native rather than rejecting useful source or emulating process-dependent names. - **Artifact design:** Fingerprint the canonical source path and content, complete aspect rules, visitor order and implementation content, the DI AOP generator source, installed `nikic/php-parser` identity, and `PHP_VERSION_ID`. Read only the embedded header on a hit. On a miss, generate to a collision-free encoded path and publish with `Filesystem::replace()`. Reject an invalid target before writing that target's proxy or publishing any loader-map entry; a rejection anywhere in the batch publishes no map. Valid artifacts written earlier in a failed batch remain safe fingerprinted cache entries for a later boot. Document the supported one-named-class-like source shape in the AOP guide. -- **Cleanup and cross-package implications:** Remove dead `Ast::parseClassByStmts()`, `ProxyManager::getAspectClasses()`, the dispatching `ProxyTrait`, and their obsolete fixtures/tests; retain `RewriteCollection::getShouldNotRewriteMethods()` and the test-owned `AspectCollector::forgetAspect()`. Route `ServiceProvider::aspects()` through `ClassMetadataCache::reflectClass()` to complete `reflection-04`. The full `filesystem` audit retained checked directory creation; the later full `foundation`, `support`, `sentry`, and `telescope` audits must retain and revalidate the remaining boundaries. +- **Cleanup and cross-package implications:** Remove dead `Ast::parseClassByStmts()`, `ProxyManager::getAspectClasses()`, the dispatching `ProxyTrait`, and their obsolete fixtures/tests; retain `RewriteCollection::getShouldNotRewriteMethods()` and the test-owned `AspectCollector::forgetAspect()`. Route `ServiceProvider::aspects()` through `ClassMetadataCache::reflectClass()` to complete `reflection-04`. The full `filesystem` audit retained checked directory creation; Foundation, Support, and Sentry revalidated their consumers, while Telescope remains pending. - **Regression strategy:** Compare proxied and unproxied execution for omitted, positional, named-skipped, numeric-variadic, and named-variadic argument shapes. Cover global, imported, namespace-shadowed, and explicit-relative argument-introspection calls; valid named `func_get_arg`; unchanged first-class callables; and deterministic rejection of literal indirect and builtin-resolving unpacked calls while definite custom functions remain untouched. Cover method-local statics, default-object identity, caller-visible mutation by original bodies and aspects, reference variadics, nested magic constants, private/static/trait/aliased/void/never methods, deterministic pre-publication rejection, fingerprint inputs, same-mtime changes, encoded-name collisions, failed native boundaries, atomic publication, exact marker detection, loader restoration, and real Sentry/Telescope interception. - **Implementation:** Generated proxies now retain each original method signature as the advice wrapper and move its body to one collision-checked private helper. One stateless dispatcher owns generated and manual aspect calls, and one empty marker trait identifies proxied classes, enums, traits, aliases, inherited proxies, and classes composing multiple proxied traits without reserving a method name. Direct argument-introspection calls preserve the original frame across positional, named, skipped, and variadic calls; unsafe indirect or unpacked forms fail before publication. Magic constants, nested PHP 8.4 closure descriptors, method-local statics, default-object identity, references, `void`, and `never` retain native behavior. Proxy artifacts use collision-free encoded paths, checked native boundaries, complete content fingerprints, header-only cache hits, atomic replacement, and all-or-nothing loader-map publication. Class-map tests isolate and restore their Composer loader, Support uses the canonical reflection cache, and failed directory creation now throws after allowing a concurrent creator. - **Regression tests:** Native and proxied twins cover every supported argument shape and both native `func_get_arg()` failure messages. Focused coverage also pins function-import and namespace fallback rules, rejected dynamic forms, method-local state, object defaults, caller-visible references, variadics, private/static/trait/aliased/enum/void/never methods, exact and inherited marker detection, multiple proxied traits, nested closure-descriptor format, leading-backslash normalization, generated-name collisions, source-shape rejection, every fingerprint input, same-mtime changes, encoded filename collisions, native boundary failures, batch publication, loader restoration, and real Sentry and Telescope interception. - **Performance and complexity:** Proxy cache hits perform boot-only content hashing and a header read instead of unconditional parsing. Seven alternating warmed benchmark rounds of the complete intercepted path, with 400,000 calls per round, measured the old design at a 2,679.1-nanosecond median and the new design at 2,474.6 nanoseconds, a 7.63% improvement. Request-time dispatch removes the old `__proxyCall()` to `handleAround()` layer while adding the private helper call. No mutex, coroutine context, retry loop, runtime registry, or compatibility path was added; compiler complexity exists only where native PHP behavior must be preserved. - **Laravel-facing result:** Laravel has no native AOP API. Hypervel's documented aspect registration, target declarations, join point, advice pipeline, and configuration remain unchanged. Unsupported source shapes now fail clearly before publication. The owner-approved Filesystem change corrects silent directory-creation failure to the method's existing exception contract without changing supported call shapes. - **Validation and review:** Focused DI, Filesystem, Foundation AOP, Support, Sentry, and Telescope tests are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference scans, package-checklist parity, full fresh caller/callee and generated-code review, and independent code review are complete. The final review verified native PHP 8.4 behavior, proxy fingerprints and publication, marker composition, loader isolation, filesystem boundaries, and the deliberate rejection of method-local named class declarations. +- **Later Sentry revalidation:** `di-02` remains intact when Sentry is active, including the stateless generated dispatcher and Guzzle interception. An inactive Sentry provider now registers no Guzzle aspect at all, so it cannot force unusable proxy work; no DI source change was required. - **Assessment:** The final design fixes demonstrated semantic, cache-validity, publication, filesystem, and test-isolation defects at their owning boundaries. It deletes the old duplicate dispatch path and dead metadata, improves the measured intercepted hot path, and adds no speculative synchronization, compatibility layer, or general source-transformation framework. - **Owner pre-commit review:** The owner reviewed the completed work-unit summary and approved committing the signed-off implementation. @@ -618,6 +619,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `coroutine-05` | Defect | Major | High | Filesystem and LockableFile unconditionally unlock in `finally`, so canceling a waiter that never acquired ownership releases another coroutine's gate | Record acquisition in one local boolean at each owning callback boundary and unlock only the exact acquired gate; retain Locker's distinct single-flight semantics and add no callback abstraction | | `coroutine-06` | Defect | Major | High | `Coroutine::fork()` replicates parent context inside the child before caller cleanup begins, so a throwing user `replicate()` is reported as child failure while Concurrent capacity and Parallel/Concurrency wait counts remain stranded | Capture and replicate the source map transactionally in the parent before spawning, merge it in the child, and preserve one source plus one target selection without a handshake, registry, lock, retry, or per-consumer copy implementation | | `coroutine-07` | Defect | Major | High | `WaitConcurrent` inherits `fork()` without adding the forked child to its wait group, so `wait()` can report completion while work is still running | Override `fork()` with the same add, child-finally, and synchronous-failure rollback contract as `create()` | +| `coroutine-08` | Defect | Major | High | `fork()` installs copied context after `afterCreated` callbacks, so Sentry and Telescope callbacks observe the parent or overwrite capture-time values | Install copied fork context before callbacks while ordinary `create()` starts with an empty context | | `foundation-02` | Defect | Minor | High | Foundation's testing Waiter performs replicated context copy before registering its result defer, so replication failure becomes a misleading timeout | Capture parent context synchronously and install it in the child before registering result delivery | | `websocket-server-01` | Defect | Minor | High | WebSocket context copy reads an absent source file descriptor through an unchecked raw array offset | Treat a missing source descriptor as a no-op, matching CoroutineContext copy behavior | @@ -634,6 +636,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Assessment:** The final design fixes demonstrated creation, reporting, ownership, wait-tracking, retained-state, and context-copy defects at their lowest boundaries. It adds no lock, registry, retry, handshake, timeout policy, cancellation framework, or compatibility layer; removes duplicate reporting and stale map entries; and adds no meaningful hot-path cost. - **Later Filesystem revalidation:** The full Filesystem audit removed its worker-local `Locker` layer and the two acquisition booleans introduced for `coroutine-05`, replacing them with checked native file-lock ownership. Cancellation can no longer release another coroutine's worker-local gate because no such gate remains. Coroutine's own Locker semantics and regressions are unchanged. - **Later WebSocket Server revalidation:** The full WebSocket Server audit retained the `websocket-server-01` missing-source no-op and extended the same context boundary with dotted deletion, key-existence semantics, and null-preserving filtered copying. Focused context coverage and the complete gate remain green. +- **Later Sentry revalidation:** `coroutine-08` installs a fork's captured values before creation callbacks, so Sentry's Hub clone observes the selected snapshot instead of stale parent state. Ordinary `create()` retains parent propagation through the callback, and Telescope remains pending for the same callback-order revalidation. ### Make process concurrency transport lossless and reconstruct failures safely @@ -777,18 +780,21 @@ Append package entries in checklist order. Keep each entry compact but complete | `object-pool-02` | Userland footgun | Minor | High | Factory flush and recycler configuration/lifecycle methods mutate singleton worker-wide state without consistently documenting their boot/test boundary at both contract and concrete surfaces | Add the standard warnings with concrete worker-wide consequences to `flush()`, `setInterval()`, `setTimer()`, `start()`, and `stop()`; leave runtime operations and pure readers unwarned | | `object-pool-03` | Improvement | Improvement | High | Object Pool, SimpleObjectPool, PoolManager, and the Sentry pool retain and forward an unread application Container while the split package directly requires Carbon after its only user was deleted | With owner approval, remove the false constructor/property/import chain and every construction-site argument, drop only the stale direct Carbon requirement, and correct only the obsolete constructor snippets in the completed lifecycle plan | | `object-pool-04` | Container identity defect | Major | High | Resolving concrete `PoolManager` or `PoolRecycler` separately from their contracts creates multiple worker-lifetime registry or timer owners | Alias each concrete to its canonical contract so every resolution shares one owner while preserving later application bindings | +| `object-pool-05` | Capability defect | Major | High | Managers recognize concrete pooled wrappers, so transparent decorators and custom pool owners cannot be invalidated through their published outer object | Add `InvalidatesPool::invalidatePool()` and forward it through pool and filesystem proxies to the actual owner | +| `object-pool-06` | Diagnostic defect | Minor | High | Pool fingerprint conflicts recommend sharing a name or purging even when two live definitions differ in construction | Recommend distinct identity, or a matching explicit fingerprint only when construction is genuinely equivalent | - **Owner-approved improvement boundary:** The owner approved `object-pool-03` after reviewing its practical benefit, constructor churn, leave-as-is alternative, parity effect, hot-path effect, and overengineering assessment. Keep `hypervel/container` and `hypervel/contracts` because PoolErrorReporter, StartRecycler, and PoolManager's custom pool-factory resolution still legitimately use them. Remove no compatibility shim: these constructors are Hypervel-specific, Laravel parity is unaffected, and Hyperf API parity is not required. - **Important rejected concerns:** Do not add manager locking, proxy pool caching or generation tracking, destructor/native-teardown changes, worker-exit flushing, protected-channel ownership assertions, generic discard-on-operation-exception behavior, discard-time activity stamping, speculative test barriers, per-method recovery within one failing pool, or a generic contextual reporting API. The current manager publishes only after synchronous construction; leases and proxies already own terminal cleanup; pool teardown is explicitly native-free where destructors can reach it; supported lifecycle closure is deterministic; and the recycler needs only one direct per-pool catch. -- **Cross-package implications:** Sentry construction sites and affected Object Pool consumers/tests must follow the constructor correction. No completed lower-level assumption changes and no later revalidation dependency is introduced. +- **Cross-package implications:** Sentry construction sites and affected Object Pool consumers/tests follow the constructor correction. Filesystem and Sentry later completed `object-pool-05` and `object-pool-06` at the shared capability and diagnostic owners. - **Performance and compatibility:** Normal pool get, borrow, release, discard, proxy, request, job, and transport paths gain no work. Recycler maintenance gains one direct try/catch per registered pool at the existing ten-second interval; the identity wrapper allocates only after an actual failure. Constructor cleanup marginally reduces pool creation work and retained references. No Laravel-facing API, configuration, documented behavior, or conventional extension pattern changes. - **Regression strategy:** Prove that a first pool failing in `sweepExpired()` is reported with its identity and exact original failure, does not continue to `trimIdle()`, and cannot prevent a second pool from completing all maintenance methods. Update constructor coverage and all affected callers, run each changed test file immediately, run focused Object Pool and affected consumer tests, validate the split manifest, run `composer fix`, then perform a fresh caller/callee, lifecycle, API, performance, stale-code, and overengineering review before independent code review. - **Implementation:** PoolRecycler now isolates each complete pool maintenance transaction, reports an identity-named wrapper with the original failure chained, and documents why a throwing public-contract pool cannot be allowed to starve unrelated pools; the existing timer-level catch remains for factory-snapshot failure. Factory and Recycler contracts now carry the same worker-lifecycle warnings as their concretes. ObjectPool, SimpleObjectPool, PoolManager, and Sentry's transport Pool no longer retain or forward an application Container; every caller and test uses only the real constructor dependencies. The stale direct Carbon requirement is removed, while the still-used Container and Contracts dependencies remain. The completed Object Pool lifecycle plan describes the corrected constructors without altering its legitimate container behavior. - **Later Socialite revalidation:** `object-pool-04` aliases `PoolManager` to Factory and `PoolRecycler` to Recycler, giving each worker one registry and timer owner without an explicit singleton binding. Focused provider coverage proves concrete and contract resolutions observe the same pools and interval state while existing application bindings still win. +- **Later Sentry revalidation:** `object-pool-05` gives `PoolProxy`, `ClientPooledFilesystem`, and Sentry filesystem decorators one capability-neutral invalidation chain; `object-pool-06` removes misleading recovery advice from construction-fingerprint conflicts. Normal lease operations are unchanged, and the capability is consulted only during explicit purge/invalidation. - **Regression tests:** The new recycler regression proves the old first-pool `sweepExpired()` failure is reported with its registry identity and exact original throwable, skips that pool's `trimIdle()`, and cannot prevent a later pool from completing `isIdle()`, `sweepExpired()`, and `trimIdle()`. Updated Object Pool, Filesystem, Queue, Broadcasting, and Sentry coverage exercises every corrected constructor and container-resolved PoolManager binding without weakening existing assertions. - **Validation and review:** Every changed test file passed immediately. The Object Pool package passes with 168 tests and 411 assertions; Sentry with 241 tests and 673 assertions; focused Queue manager resolution with 10 tests and 86 assertions; and Broadcasting manager integration with 20 tests and 66 assertions. Final `composer fix` changed no formatted file, both PHPStan configurations are green, 23,196 component tests and 66,057 assertions pass with 1,600 expected skips, 346 Testbench contract tests and 1,029 assertions pass with 3 expected skips, and 4 dogfood tests and 7 assertions pass. The split manifest validates strictly, `git diff --check` and repository-wide stale-constructor/Carbon scans are clean, and fresh full-diff lifecycle, API, performance, and overengineering review found no omission. Independent code review requested one useful WHY comment, re-reviewed it after the focused 19-test/57-assertion recycler run, and signed off. The owner reviewed the final package summary and approved committing. - **Laravel-facing result:** No Laravel public API, configuration key or structure, documented behavior, or conventional extension pattern changes. Object Pool and Sentry's pool constructor are Hypervel-specific; Hyperf parity is not required. The owner-approved constructor cleanup intentionally removes false Hypervel-specific arguments without a compatibility shim. -- **Assessment:** All four accepted findings are closed. Unrelated pools remain maintainable after one supported custom pool fails, lifecycle controls state their true worker-wide boundary, and constructors advertise and retain only real dependencies. The result adds only one cold per-pool try/catch at the existing maintenance interval and failure-only diagnostics, while deleting false dependencies and retained references. No request-hot-path work, lock, context state, registry, retry, timeout, cache, worker-exit hook, reporting abstraction, compatibility layer, workaround, or speculative machinery remains. +- **Assessment:** All six accepted findings are closed. Unrelated pools remain maintainable after one supported custom pool fails, lifecycle controls state their true worker-wide boundary, constructors advertise only real dependencies, and pool invalidation no longer depends on concrete wrapper types. The result adds only one cold per-pool try/catch at the existing maintenance interval, an operational-path capability check, and failure-only diagnostics while deleting false dependencies and retained references. No request-hot-path work, lock, context state, registry, retry, timeout, cache, worker-exit hook, reporting abstraction, compatibility layer, workaround, or speculative machinery remains. ### Expose process stopping through the contract @@ -874,6 +880,8 @@ Append package entries in checklist order. Keep each entry compact but complete | `filesystem-10` | Improvement | Improvement | High | Cloud `readStream()` defaults spool complete remote objects before consumers can read, increasing peak memory and delaying first-byte delivery under concurrent Swoole workloads | With owner approval, default S3 and GCS `stream_reads` to true, document the connection-reuse tradeoff and false opt-out, and prove lazy coroutine-safe consumption and cleanup deterministically | | `filesystem-11` | Improvement | Improvement | High | Hypervel lacks current Laravel's public `assertEmpty()` storage testing assertion and the scoped/pooled forwarding and metadata needed for consistent use | With owner approval, port the current Laravel API, tests, facade metadata, forwarding, and concise testing documentation without adding it to the Filesystem contract | | `filesystem-14` | Static-analysis type defect | Minor | High | Mail and ServeFile falsely narrow supported disks to `FilesystemAdapter`, excluding pooled adapters that provide the required dynamic methods | Keep the truthful Filesystem contract and use exact local suppressions for the adapter metadata methods every shipped disk provides | +| `filesystem-15` | Construction identity defect | Major | High | Builds, custom creators, scoped reconstruction, and concrete purge checks can lose the configured logical disk name or miss pooled decorators | Carry the nullable logical name through every construction path, include it in whole-driver identity, and invalidate through `InvalidatesPool` | +| `filesystem-16` | Serving capability defect | Major | High | Local-driver classification prevents decorated and custom disks that explicitly opt in from receiving signed serving routes | Register routes only for exact `serve => true` and require opted-in disks to provide the serving capability | | `foundation-04` | Defect | Major | High | Kernel and application termination stop after the first listener, middleware, application, duration, or context failure, allowing independent request cleanup to be skipped | Run each fixed termination phase independently, preserve the earliest failure, and keep context removal as final belt-and-suspenders cleanup | - **Native lock design:** Under Swoole 6.2.2, any `LOCK_NB` request goes directly to one native `flock` attempt; only bare `LOCK_EX` and `LOCK_SH` enter Swoole's 1-to-100-millisecond coroutine backoff. Current locked `put()` and `sharedGet()` therefore wait through Hypervel's fixed one-millisecond polling for both same-worker and cross-process contention. Checked blocking native calls move those waits to Swoole's adaptive backoff, while nonblocking `LockableFile` and Cache FileStore paths remain immediate and no longer queue behind a worker-local gate. This reduces wakeups under sustained contention; the owner accepted that a lone waiter behind a long-held lock can observe release up to roughly 100 milliseconds later. No timeout, configurable polling policy, replacement gate, or compatibility path is added. `append(lock: true)` and bare blocking `LockableFile` calls were already native. @@ -888,6 +896,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Regression tests:** Deterministic coverage proves native lock serialization, immediate nonblocking failure, cancellation safety, partial and zero-progress writes, checked native and MIME failures, cleanup precedence, cache add and refresh behavior, exhaustive deletion without iterator-order assumptions, zero modes, link and replacement failures, metadata false results, adapter read protection, upload-stream ownership, JSON unions, signed nested paths, assertion forwarding, lazy remote reads, scheduler progress, disconnect termination, stream and lease release, exact CRLF/CR/LF SSE framing, committed failures, exhaustive Foundation and HTTP Server termination, receive-file failure, and exact `stream_close` ownership. - **Cross-package revalidation:** `filesystem-01` retains its typed custom-creator boundary; `filesystem-02` retains checked directory creation; `filesystem-03` and `encryption-03` retain sensitive atomic replacement; `support-02` retains enum disk normalization. The obsolete Filesystem acquisition booleans from `coroutine-05` were removed with their worker-local lock layer, while the corrected Coroutine Locker primitive remains unchanged. Cache's nonblocking FileStore behavior, HTTP's response construction, and Foundation and HTTP Server response lifecycles pass their affected coverage. The later full HTTP audit revalidated `http-02` and `filesystem-07` against current request, response, event-stream, and Swoole bridge coverage without changing Filesystem ownership. - **Later Mail revalidation:** `filesystem-14` removes runtime-false concrete annotations from Mail storage attachments and ServeFile while retaining the intentionally narrow Filesystem contract. Exact local PHPStan suppressions cover only adapter methods available on every shipped disk; storage integration and ServeFile regressions verify the dynamic boundary without reflection, a capability interface, or runtime branching. +- **Later Sentry revalidation:** `filesystem-15` passes a configured disk's logical name to built-in and custom creators, scoped reconstruction, labels, signed URLs, and whole-driver fingerprints; S3/GCS client pools remain name-independent. Anonymous `build()` retains null identity separately from the valid configured name `ondemand`, so it enters a private construction helper rather than the protected configured-disk `resolve()` seam. `callCustomCreator()` and `createScopedDriver()` gain an optional name, and custom creators receive a nullable third argument; these filesystem-specific extensions close a real built-in/custom asymmetry and are not a template for unrelated managers. Explicitly equivalent custom whole-driver construction may converge only through a matching explicit fingerprint. `filesystem-16` intentionally differs from Laravel's local-only classification: exact `serve => true` is the complete boot-time predicate for any capable shipped or custom disk, while false, absent, and truthy non-booleans register nothing. Purge follows `InvalidatesPool` through decorators and may resolve an uncached configured disk on this cold operational path. - **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,282 tests, 66,332 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off on the final MIME, SSE, documentation, test, and ledger corrections. - **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, the bounded native lock-detection tail, and protocol-correct multiline SSE framing where current Laravel emits invalid subsequent data lines; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity. - **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work; single-line SSE events add one native scan without normalized-data allocation, while multiline events incur only the bounded native string work required for correct framing. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering. @@ -998,6 +1007,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `core-06` | Defect | Major | High | Truthy `event_object` changes numerous native Swoole callback signatures that Core's positional lifecycle bridge cannot consume | Reject the setting at the global and per-port settings mutation boundaries and direct users to Hypervel lifecycle events instead of adding a dual callback adapter | | `core-07` | Defect | Minor | High | `OnReceive::$data` is wider than its sole string caller, while three public Hyperf-era classes are empty or unconsumed and imply unsupported alternatives | Narrow receive data to string and remove `ServerStartCallback`, `NotImplementedException`, and `ConsoleLogger` completely | | `core-08` | Package metadata defect | Minor | High | Core omits its direct Swoole requirement and provenance while declaring an unused Coroutine dependency and carrying stale logger descriptions | Require `ext-swoole`, remove the false dependency, record Hyperf framework provenance, and correct concise configuration and logging guidance | +| `core-09` | Defect | Major | High | Swoole can invoke the native worker-exit callback repeatedly while reactor work remains, replaying framework exit listeners and cleanup | Guard the callback instance before dispatch, dispatch once, and always resume the exit coordinator in `finally` | - **Approved owner gates:** The owner approved rejecting truthy global/per-port `event_object`, declaring `task_enable_coroutine=false`, enforcing the PSR unknown-level exception, deleting the three unused public classes, and the measured approximately 58-nanosecond dynamic-value escaping guard on enabled line-format logs. - **Important rejected concerns:** Do not add per-log Config reads, PID checks, configuration observers, an optional refresh interface, logger recreation, a recursive JSON normalizer, Monolog dependency, stdout mutex, dual `event_object` adapter, worker-start `finally`, broad listener guards, request/coroutine logger state, event renaming, or exhaustive tests for trivial event DTOs. The supported stdout path performs one write per line and no demonstrated interleaving failure justifies worker-wide serialization. @@ -1008,7 +1018,8 @@ Append package entries in checklist order. Keep each entry compact but complete - **Implementation:** The default stdout logger now reloads validated worker configuration after Foundation's stable-repository rebuild, uses a precomputed level map, performs safe PSR interpolation and conditional line escaping, and emits resilient raw JSON with informative top-level object and resource markers. Task callbacks select Swoole's exact legacy or native-object signature and finish boundary from all dedicated settings. Global and per-port settings reject incompatible truthy `event_object` values at their complete mutation boundaries while allowing explicit false. Receive data is narrowed to string; the three dead public classes and false Coroutine dependency are removed; and package provenance, the direct Swoole requirement, shipped defaults, and user guidance are current. - **Regression tests:** Stdout coverage exercises safe scalar, array, resource, object, date, invalid-UTF-8, recursive, and throwing-serializer context; literal Symfony markup and percent-bearing tags; raw JSON; custom, disabled, invalid, and non-string levels; invalid formats; and failed-reload atomicity. Worker-start coverage proves reload ordering before startup output and readiness while custom loggers remain untouched. Task coverage proves legacy construction and completion, every native-object setting, and legacy-alias precedence; a live-server test solely for the final native `Task::finish()` call was rejected as disproportionate because the final extension class has no honest unit seam and Swoole source defines that ownership boundary. Server coverage proves construction-time and later global/per-port `event_object` rejection, port-specific diagnostics, explicit-false acceptance, and the shipped disabled coroutine-task default. - **Validation and review:** The focused affected group passes with 81 tests and 229 assertions. PHP CS Fixer changes none of 5,580 files; both PHPStan configurations pass; the complete components suite passes with 23,410 tests, 66,742 assertions, and 1,603 expected skips; Testbench passes with 347 tests, 1,031 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. Root and split Composer validation, PHP syntax checks, `git diff --check`, broad stale-reference and settings-writer scans, a fresh full-diff caller/callee, lifecycle, API, documentation, hot-path, and overengineering review, and independent code review are complete. The final review improved top-level resource JSON normalization and signed off with no remaining issue. -- **Assessment:** All eight verified Core findings are fixed at their lowest owning boundary without a compatibility shim, dual callback adapter, per-log configuration lookup, synchronization layer, recursive normalizer, or speculative test seam. Cold boot and callback-construction paths own the new validation and selection work; ordinary logging uses a faster level lookup and only the approved small escaping guard. The result contains no stale surface, retained request state, hot-path regression, workaround, or unresolved finding. +- **Assessment:** All nine verified Core findings are fixed at their lowest owning boundary without a compatibility shim, dual callback adapter, per-log configuration lookup, synchronization layer, recursive normalizer, or speculative test seam. Cold boot and callback-construction paths own the new validation and selection work; ordinary logging uses a faster level lookup and only the approved small escaping guard. The result contains no stale surface, retained request state, hot-path regression, workaround, or unresolved finding. +- **Later Sentry revalidation:** `core-09` makes the existing callback instance the exact once-only owner of framework worker-exit dispatch and coordinator resume. Sentry's best-effort transport shutdown therefore runs once even while reactor-owned sends remain active; Server's one-instance callback resolution and registration were revalidated without source changes. ### Complete Foundation runtime lifecycles and safe publication @@ -1118,6 +1129,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and complexity:** No request or response hot path changes. Native checks occur beside existing boot calls; reload validation replaces unsafe probes; process titles add one typed Config lookup only when a process lifecycle event fires; and the inter-worker guard adds only exception frames around existing native callbacks. Tests, metadata, and docs have no runtime cost. The design adds no lock, retry, registry, polling, coroutine state, compatibility shim, or retained memory. - **Laravel-facing result:** Server is Hyperf-derived Swoole infrastructure and has no Laravel API parity obligation. The approved API cleanup removes a nonfunctional Hyperf surface while making explicit custom implementation injection more flexible. Foundation's Laravel-shaped `serve` and aggregate `reload` commands retain their public call shapes. - **Cross-package revalidation:** The carried `core-06` setting boundary remains complete: construction and every supported later global/per-port mutation reject truthy `event_object` before native publication. Foundation's stable Config identity, best-effort aggregate reload helper, gRPC listener insertion, Server Process boot listener, and Testbench serve wrapper remain compatible with the corrected Server contracts. Reverb completed `server-10` at Server's native inter-worker callback owner and removed any need for a package-local guard. +- **Later Sentry revalidation:** Server still resolves and registers one `WorkerExitCallback` instance per worker. The `core-09` guard therefore makes framework exit dispatch exactly once without adding Server source, waiting inside the native callback, or changing the reactor-owned best-effort shutdown window. - **Implementation:** Server now fails immediately when native settings, callback registration, listener creation, or startup reports failure. Reload reads and validates one positive PID, checks the actual required signals, and reports success only after every configured worker group is signaled. Process titles read the stable Config repository at each lifecycle event; public builders have valid Swoole defaults; omitted HTTP types remain consistent through `serve` address overrides; and invalid configuration uses package exceptions. Inter-worker pipe and task-finish callbacks contain cancellation and report other failures without letting a reporter failure escape. PHPStan discovers Swoole's authoritative Server signature, so the checked `Port|false` result narrows natively without the former imprecise `bool|Port` annotation. The dead top-level server-class field and interface constructor are removed while explicit custom implementations remain injectable. Split dependencies, provenance, deployment guidance, and exact test environment ownership are truthful. - **Regression tests:** Deterministic native mocks cover every boolean/union failure boundary without adding production seams. Command tests cover missing configuration, unreadable and malformed PID files, positive validation, event/task signal success and failure, partial success, truthful output, and exit status. Additional coverage proves stable-repository title reloads, valid omitted defaults and address overrides, package exception consistency, custom interface construction, complete split metadata, environment restoration, the carried global/per-port `event_object` guards, and contained pipe/task-finish cancellation, reporting, and reporter-fallback behavior. - **Validation and review:** Every changed test file and the focused Server, Foundation config/reload, gRPC, Server Process, Testbench, and Reverb callback group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split Composer validation, `git diff --check`, broad stale-reference/dependency/config-classifier scans, a fresh caller/callee, API, lifecycle, performance, and overengineering self-review, and independent code review completed with no remaining finding. @@ -1253,7 +1265,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved rejecting pooled RESET and sharded subscription, deleting automatic framework replay and the incompatible named-proxy extension surface, changing Reverb outages to truthful publish failures, adding Laravel-shaped boot-only event controls and macros, retaining connection-local topology, and the source-proven noise-level command-name normalization and macro lookup. Connector-driver `extend()` / `setDriver()`, pooled RESET, and pooled/dedicated `ssubscribe()` are recorded at their README, source, and test locations with supported alternatives. - **Important rejected concerns:** Do not add a retry registry, command ambiguity state machine, connector hierarchy, public transport abstraction, sharded Pub/Sub router, pool retrofit mechanism, publisher queue, generic finalizer, configurable channel policy, raw-client wrapper, recursive object serializer, or compatibility layer. Idle subscriber receive remains correctly unbounded. Cluster subscriber transport follows only `cluster.context`, matching phpredis node sockets; unsupported endpoint ambiguity is rejected rather than guessed. The identical two Cache limiter callback sites remain accepted under `redis-12` for the active Cache work; they require the same direct precedence correction, not a shared abstraction. - **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology, config-owned application prefix default, both prefix precedence levels, facade metadata, and static cleanup are complete. The later Queue work made SafeScan reject transformed connections before any scan/deletion and reconciled every case-insensitive command signature plus the held-transaction discard boundary. The Horizon work added callback-sensitive MultiExec return contracts without changing runtime behavior. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. -- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, Session, and Broadcasting; Broadcasting now consumes canonical connection prefix precedence and separates native Cluster publication from manually prefixed Lua arguments. `redis-15` revalidated Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `redis-23` is complete through Horizon's Cluster-aware batching and exact result shapes. `horizon-01` is complete after Horizon's full audit; `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. +- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, Session, and Broadcasting; Broadcasting now consumes canonical connection prefix precedence and separates native Cluster publication from manually prefixed Lua arguments. `redis-15` is complete for Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `redis-23` is complete through Horizon's Cluster-aware batching and exact result shapes. `horizon-01` is complete after Horizon's full audit; `sentry-01` is complete after Sentry revalidated the normalized database key on both success and failure paths; `telescope-01` and `telescope-02` remain for Telescope's later full audit. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. - **Later Routing revalidation:** `redis-24` corrects DurationLimiter's fresh and expired tuple order and clamps public over-limit remaining counts. Real-Redis coverage revalidates Redis and Routing while ordinary throttles retain one atomic acquire round trip. - **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; callback and no-callback MultiExec result shapes; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon config defaults, topology, prefix publication, and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; raw SafeScan/FlushByPattern operation and transformed-mode rejection before deletion; all case-insensitive command pairs and held-transaction discard semantics; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. - **Performance and complexity:** Ordinary proxy commands add two lowercase operations on a short method name, one static macro-table lookup, and fixed guard comparisons; these are owner-approved measurement-noise costs with no config read, container lookup, lock, context operation, yield, retry, log, reconnect, or extra network command. Failure classification, topology, option normalization, observability, boot overrides, SafeScan's one construction-time raw-mode guard, and command metadata stay on cold or exceptional paths. Exact subscriber parsing removes one timer coroutine per message, and direct Reverb publishing removes unbounded retained outage memory. No new registry, retry queue, state machine, or unbounded worker state is retained. @@ -1287,6 +1299,8 @@ Append package entries in checklist order. Keep each entry compact but complete | `cache-18` | Improvement | Improvement | High | Seven Cache container array-access sites hide types or mutation semantics | Replace them with typed class-string/contract `make()` and Config repository writes while retaining the memo spy's bound precheck | | `cache-19` | Event-contract defect | Minor | High | Custom All/Any/Stack reads, writes, failures, and non-positive-TTL deletes omit events emitted by the corresponding Repository operation | Restore exactly the established event set and no more, with two shared protected Any-mode plain-key helpers and focused event-order regressions | | `cache-20` | Type and maintenance correction | Minor | High | Cache's striped-lock constants are untyped while its deterministic timeout override depends on late static binding | Type the three constants and the test override, preserve late binding only for timeout/spin tuning, and document that seam | +| `cache-21` | Event lifecycle defect | Major | High | One-key and many-key reads have no failure terminal when store access or incomplete-class normalization throws | Emit exact retrieval-failure events carrying the exception, then rethrow | +| `cache-22` | Event lifecycle defect | Major | High | Repository write and forget starts can remain unterminated when the store throws | Emit the existing per-key write/forget failure terminals, then rethrow | - **Approved owner gates and intentional differences:** The owner approved the secure serialization default, current Laravel APIs/configuration, the explicit nullable `cache.limiter` key, permanent any-mode additions, removal of hidden callback pinning, exact Redis factory injection, typed container modernization, complete tagged-event behavior, and every source-proven noise-level hot-path cost. Redis any-mode `add()` remains atomic and event-free with or without a TTL; all-mode and stack null-TTL additions use read-then-write fallbacks and therefore emit retrieval/write events. This capability difference is documented at the public Events surface. - **Important rejected concerns:** Do not add Redis prefix clearing from an unmerged topic branch, storage tags/locks/atomic add, generic finalizers, retry/state-machine machinery, timer registries, scan schedulers, replacement pinning, flexible-marker translation, per-layer event documentation, lock-message harmonization, compatibility shims, or a speculative increment/decrement base-key correction. Do not reject documented native PhpRedis serializers under the shipped non-null security default, toggle connection serializers, decode raw transport payloads, or add a Cache-owned format wrapper; those approaches would disable a supported feature or fork the stored representation. Do not retain the six remember operation classes for benchmark callers: the benchmark resolves a Repository, not RedisStore. Failover and memo outer repositories disable events by default, so no per-layer event correction is needed. @@ -1295,6 +1309,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and complexity:** Redis hits retain one checkout; misses replace one lease held across arbitrary user code with two short local pool checkouts and no extra Redis command. `rememberWithWarmth()` adds one two-element array allocation. Fixed-width formatting is noise beside filesystem I/O. Restored tagged pre-events add one closure allocation and listener lookup at each missing event site, negligible beside Redis/Lua/stack I/O but explicitly owner-approved. Factory injection removes repeated container resolutions, and scan changes reduce retained memory and lease duration. Timer ownership retains one small worker-0 ID list and clears it only at worker exit; lock-constant typing has zero runtime effect. No new registry, retry loop, state machine, context slot, lock layer, compatibility path, or unbounded worker state is added. - **Implementation:** Added the Storage driver, `rememberWithWarmth()`, closure TTLs, incomplete-class handling, the secure serialization policy, current facade/configuration metadata, and the full accepted documentation surface. Flexible identities now include the resolved namespace; lock, limiter, failover, Stack, and Swoole permanence paths preserve exact ownership and failure precedence; Redis tag scans borrow per page or chunk, follow SafeScan cursors, and no longer hold connections across callbacks or sleeps; permanent any-mode additions are atomic; and custom tagged operations publish the established event sequence with truthful store names. The original Cache work recorded successful native timer-ID retention but shipped only partial-startup rollback; the Reverb work completed `cache-11` by retaining the committed IDs on the exact listener and clearing every ID on worker exit. It also completed `cache-20` by typing the striped-lock constants without losing the existing late-bound deterministic timeout/spin seam. The six hidden remember operations, their tests, the unused Redis operation containers and exception surface, duplicate factory resolution, dead call-site defaults, stale comments, and superseded documentation are removed. - **Cross-package revalidation:** `filesystem-04` remains compatible with the Storage driver's checked `Filesystem` contract and failure results. `redis-09` confirms Cache never retries Redis work, `redis-12` is completed at both Cache limiter callback owners, and `redis-13` preserves the normalized Redis configuration and prefix boundary. `container-08` and `container-09` remain intact through canonical service-key resolution, enum-aware manager routing, and null-only default selection. `support-02` remains complete across Cache keys, tags, events, commands, the facade, and raw failover reads. `session-23` was completed at Cache's `SessionStore` boundary by storing session-cache entries in a flat map of literal keys. Reverb completed the unshipped timer-retention half of `cache-11` and revalidated `cache-20`; focused Cache coverage and the full gate revalidated the completed package. Auth and Sanctum now point to the Cache-owned serialization guidance, their model fixtures remain readable under the secure default, and Testbench inherits that default through the merged framework configuration. +- **Later Sentry revalidation:** `cache-21` and `cache-22` give one-key reads, complete many-key reads, writes, and forgets exact success/failure terminal pairs. Many-read normalization completes before any per-key hit/miss events, so a late handler failure cannot publish partial success. Empty finite-TTL aggregate writes return true before starts or store I/O in Repository and both Redis tag modes. Many-key event owners publish resolved `list` keys, which lets Sentry consume the exact contract directly and delete its Laravel-only associative-key normalizer. New failure construction remains `hasListeners()`-guarded and occurs only on the cold exception path. - **Validation and review:** Every changed test file, the complete Cache suite, and the affected Auth, Redis/Valkey, Reverb, and Testbench paths passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-reference scans, and fresh caller/callee, event, serialization, pool-ownership, timer, lock, failure-precedence, hot-path, retained-state, documentation, and overengineering review are complete. Independent code review is signed off with every final re-review correction incorporated. - **Laravel-facing result:** Current supported Laravel Cache APIs, facade metadata, tests, documentation, and configuration behavior are restored while Hypervel preserves nullable values, the Swoole store, failover/stack stores, Redis pooling, transforms, tag modes, SafeScan behavior, and the explicit pinned-connection API. Hypervel additionally declares the documented nullable `cache.limiter` key through `CACHE_LIMITER`; the owner approved this configuration-structure difference because it makes existing provider behavior explicit without changing the unset default. Other intentional differences remain limited to verified Swoole/pooling requirements and the documented atomic any-mode add event behavior. - **Assessment:** Every accepted Cache finding and carried lower-level assumption is implemented at its lowest owner. The result removes callback-held leases, duplicate remember machinery, stale operation containers, year-long pseudo-permanence, partial timer cleanup, repeated factory resolution, and event gaps while adding only the approved noise-level local checks and events beside existing I/O. It contains no registry, retry loop, state machine, compatibility shim, runtime serializer wrapper, hot-path synchronization, unresolved accepted defect, or stale superseded path. @@ -1373,6 +1388,9 @@ Append package entries in checklist order. Keep each entry compact but complete | `queue-38` | Current Laravel parity defect | Minor | High | Beanstalk `size()` reports only ready jobs despite the standardized total metric | Sum ready, delayed, and reserved counts from one stats read | | `queue-39` | Defect | Major | High | A secondary Batchable rollback failure replaces the original timeout, preventing failed-job transaction cleanup and corrupting failure diagnostics | Keep the batch rollback catch non-capturing so the original timeout remains authoritative | | `queue-40` | Defect and parity defect | Major | High | Redis bulk enters an unsupported Cluster pipeline and both Redis and Database bulk ignore `#[Delay]` | Select transaction batching for Cluster and use the existing cached Queue attribute reader for delay | +| `queue-43` | Lifecycle/API enhancement | Major | High | Cleanup listeners cannot distinguish graceful stopping from a kill path that must return immediately | Expose `WorkerStopping::$terminatesImmediately` and set it from the actual stop/kill owner | +| `queue-44` | Event lifecycle defect | Major | High | Enqueue-start events have no exact failure terminal at the callback that actually attempts publication | Emit `JobQueueingFailed` with the unchanged payload and exception from `enqueueNow()`, then rethrow | +| `queue-45` | Bulk correctness defect | Major | High | Database and SQS bulk publication cannot produce exact per-job terminals, and Database after-commit bulk can lose transactional semantics | Preserve bulk writes while partitioning actual attempts, publishing exact starts/terminals, and deferring Database groups through reacquired queue owners | | `redis-21` | Cross-package defect | Major | High | Transformed SafeScan results break Queue inspection and can make pattern deletion silently do nothing | Reject transformed connections before scanning or deleting and document the raw held-connection requirement | | `redis-22` | Cross-package metadata and API defect | Major | High | Case-insensitive command aliases conflict and `discard()` conflates a Redis command with pool lifecycle | Reconcile command signatures, keep proxy Redis `discard()`, and expose `discardTransaction()` for held MULTI ownership | | `contracts-09` | Contract defect | Minor | High | The core Broadcaster contract requires optional concrete channel enumeration | Remove it from the contract while retaining concrete/proxy/facade support and a scoped command-side dynamic call | @@ -1382,6 +1400,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not add a poison registry, retry/tombstone state machine, generic proxy forwarding, second inspection contract, pagination/lazy-result API, transaction dispatcher service, queue schema validator, generic cleanup abstraction, SQS publication registry, pool retrofit, worker singleton current-job state, arbitrary stop timeout, Redis transformed-scan compatibility, global PHPStan rule, or extra contract methods for optional concrete capabilities. Do not retain a queue lease across user callbacks or transactions. - **Implementation:** Pooled dispatch now gives immediate work its current owner and deferred work one fresh lease, with logical name/dispatcher state reset on every borrow/release. SQS batches and overflow bodies preserve ordering, exact identifiers, publication ambiguity, and terminal cleanup. Payloads decode once; malformed jobs retain exact evidence, terminate without release, and remain observable without hiding unrelated failures. A secondary Batchable rollback failure no longer replaces the authoritative timeout or prevents failed-job transaction cleanup. Redis reservation keeps invalid bytes atomic. Workers use monotonic lifecycle state, truthful timeouts, complete stop draining, exact configuration, and current events/options/output. The later Horizon work made Redis bulk topology-aware and restored property/attribute delay semantics in Redis and Database bulk. Middleware, providers, fakes, inspection, contracts, metadata, facade docs, and public guides now match the supported surface. Superseded duplicate fake state, stale defaults/comments/ignores, ambiguous Redis declarations, and false contract requirements are removed. - **Cross-package revalidation:** Completed Contracts owns `contracts-09` and the canonical `notifications-07`; Foundation and Broadcasting are revalidated for channel enumeration; Database owns `database-14`; Redis owns `redis-21`/`redis-22`; Support is revalidated for QueueFake plus Queue/Redis facade metadata. Queue revalidated carried `queue-01`, `queue-11`, `queue-12`, `queue-14`, `reflection-04`, `events-03`, `support-02`, `bus-03`, `bus-10`, `bus-17`, `bus-18`, and `redis-13`. Broadcasting completed `queue-11` and `queue-12` revalidation through its truthful backoff property, canonical attribute reader, Bus-owned `UniqueLock`, and exact display-name lock-key regressions. The Eloquent identity work adds `queue-41`, jointly owned with Database; the completed Notifications audit revalidated both restorable model-notifiable serialization and the Factory contract. `queue-22` is complete for Horizon and remains required for Telescope; `queue-40` is complete at Queue and Horizon's dispatch boundary. +- **Later Sentry revalidation:** `queue-43` prevents telemetry drains from delaying forced or max-memory termination while retaining bounded drains for graceful stops. `queue-44` gives every actual single enqueue attempt an exact failure terminal. `queue-45` retains Database bulk inserts while separating immediate and after-commit groups, computes delays at the actual attempt, reacquires the queue for deferred work, and gives every attempted Database/SQS item an exact terminal. Sentry correlates sampled publish spans by the unchanged unique payload string, never installs them as Hub current, and creates no span for a transaction rollback before an enqueue attempt. - **Regression tests:** Deterministic coverage proves max-one-pool immediate/commit/rollback/concurrent ownership, one SQS lease per deferred batch, every overflow failure/ambiguity/cleanup transition, credentials and exact FIFO zero, one checked payload decode, poison event/report/delete behavior, timeout preservation across a throwing Batchable rollback, exact Redis raw reservation, failed-record durability, timeout/quiet/pause/drain lifecycle, middleware exactness, named Queue-connection merging versus replace-whole failed/batching blocks, provider-owned optional file defaults and atomic mode-preserving publication, real Redis inspection including literal standalone hash tags, held transactions, truthful command aliases/facades, Cluster-aware Redis bulk and Redis/Database attribute delays, disjoint QueueFake metrics and hooks, Beanstalk totals, keyless single and collection model publication rejection, restorable queued notification model identity, and all affected Horizon/Telescope/Bus/contract consumers. - **Performance and complexity:** Ordinary dispatch remains one checkout and does not allocate a per-dispatch dispatcher; a real deferred transaction adds one necessary checkout at commit and retains no lease. SQS batching reduces network calls. Valid jobs decode once. Redis invalid handling remains inside the existing Lua round trip. Pause reads use one cache `many()` call. Inspection is explicitly eager and operational: while live, its materialized collection shares the Swoole worker heap with unrelated coroutines. Its Redis removal member is the exact raw value. QueueFake and metadata changes add no production cost. No lock, retry loop, registry, context slot, compatibility shim, or unbounded retained state was added. - **Laravel-facing result:** Current supported Queue, SQS, worker, middleware, failed-provider, fake, inspection, facade, configuration, and documentation APIs are restored while Hypervel's coroutine/pool adaptations remain explicit. Verified upstream overflow, malformed-payload, fake, exact-zero, and contract defects are corrected rather than copied. @@ -1742,10 +1761,13 @@ Append package entries in checklist order. Keep each entry compact but complete | `notifications-18` | Split metadata and provenance | Minor | High | Remove false direct requirements, declare mbstring and actual trait dependencies, and record both tracked upstreams. | | `notifications-19` | Type completeness | Minor | High | Complete evidence-backed native types, fluent concrete returns, relationship generics, and scope returns without runtime wrappers. | | `notifications-20` | Test harness and source mapping | Minor | High | Use framework test bases, direct class-to-test mapping, isolated fixtures, and complete discoverable typing. | +| `notifications-22` | Event lifecycle defect | Major | High | Emit `NotificationSkipped` when `shouldSend()` rejects delivery or a `NotificationSending` listener vetoes it. | +| `notifications-23` | Lifecycle/API enhancement | Major | High | Emit `NotificationDelivered` immediately after the channel returns, before post-delivery callbacks. | - **Important rejected concerns:** Do not normalize Slack option identities, invent an image-element alternative-text limit that Slack does not publish, add an ID registry, or retain byte-counted compatibility with upstream. Retain `ReadsQueueAttributes` as Queue's intentional domain alias over `ReadsClassAttributes`; it is not dead indirection. - **Implementation and boundaries:** Notifications now exposes current storage attachment, Slack select, Builder URL, webhook, queue-precedence, and database relationship/scope surfaces. Generated action IDs are nonempty and byte-bounded without changing ordinary IDs; option values are emitted verbatim, reject empty identities, and observe Slack's 150-character maximum; static selects enforce one through 100 options and placeholders use 150 characters. All published Block Kit limits count characters, the image-block constructor no longer bypasses its alternative-text invariant, the image element enforces its documented URL limit, and Builder JSON failures retain their cause. Multibyte payloads may contain more bytes than before while remaining inside Slack's character limits; malformed over-limit text fails rather than being silently substituted. The sender saves and restores exact nested failure-attempt state in coroutine context, and the boot listener marks only active attempts. Manager aliases share the auto-singleton, while delivery/locale state remains coroutine-local. Anonymous identities, database scopes/relations, queued clones, Slack fluent chains, split metadata, docs, and test ownership have truthful types and mapping. Redundant binding, lossy value normalization, false dependencies, mocked listener simulations, stale guide limitations, raw PHPUnit bases, order-dependent fixtures, and obsolete suppressions are removed. - **Cross-package revalidation:** `notifications-07` is complete at the Contracts-owned Factory boundary. `notifications-08` and the Notifications side of `queue-41` are complete; public read/unread scopes must be called through `DatabaseNotification::query()` because same-named instance predicates shadow static scope dispatch. `support-02` remains correct across channel identifiers. `macroable-03` is complete for Cookie, Log, and Notifications, with JWT still pending. `notifications-12` revalidates Horizon's legacy webhook and modern Web API representations without changing Horizon source. This work also moved Translation's two missing-key probe globals to class-owned integration-test setup and teardown, removing verified order dependence without changing Translation source or completing its later package audit. +- **Later Sentry revalidation:** `notifications-22` and `notifications-23` make skipped and actual channel delivery explicit without changing Laravel's `afterSending()` / `NotificationSent` order. Pre-delivery failures retain the existing exactly-once `NotificationFailed` ownership, while post-delivery callback failures propagate and are never relabeled as delivery failures. Sentry closes spans on delivered, failed, or skipped terminals and retains breadcrumbs on `NotificationSent`; event construction remains listener-guarded. - **Regression tests:** Counterfactual coverage spans storage disk selection and basename/MIME behavior; select serialization, chaining, IDs, exact option identities, cardinality, placeholders, published multibyte boundaries, malformed truncation input, image constructor/element validation, and Builder JSON failures; modern/legacy Slack routing and exact Horizon payloads; queue precedence; frozen database read state and read/unread scopes; manager alias identity and concurrent local state; nested, sequential, external, successful, exceptional, and sibling-coroutine failure ownership; package metadata; generated relationship types; and process-global fixture cleanup. The database scope regressions use builder dispatch explicitly to avoid the static-call trap described above. - **Performance and complexity:** Successful channel attempts add only constant-time coroutine-context reads/writes and exact restoration around the existing transport call. No request-wide path, lock, I/O, retry, container loop, registry, pool, reflection, serialization layer, or retained allocation is added. Character counting replaces nanosecond-scale byte checks only while constructing Slack payload fields; malformed-text validation runs only on the already-over-limit truncation path, and exact option preservation removes a regex. Attachment, metadata, documentation, and test changes are cold. Removing the redundant manager binding and false dependencies simplifies construction and packaging. - **Laravel-facing result:** Current supported Laravel Notifications, MailMessage, Slack, queue, database relationship, manager, named-argument, and protected extension surfaces are preserved or restored. Changes are additive except for verified automatic-ID, option-identity, protocol-boundary, Builder-error, image-construction, and failure-ownership corrections; no public API is removed. Hypervel deliberately emits Slack option identities verbatim where Laravel lowercases and strips them, and counts Slack's published limits as characters rather than bytes. Hypervel retains coroutine-local manager state and direct Slack delivery. @@ -1967,6 +1989,39 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Changed tests passed during implementation; focused Socialite, Support, Object Pool, and Reverb coverage, root and split Composer validation, facade and documentation checks, stale-symbol scans, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, and `git diff --check` passed. Review independently reproduced the provider-namespace collision, stale config rebinding, nonce-disabled failure, partial-user memoization, and response-state risks, then signed off after every source and plan correction landed. - **Assessment:** Socialite is coroutine-safe, worker-lifecycle-aware, protocol-correct, current at the supported Laravel surface, and first-party extensible without ecosystem-manager machinery. Every accepted finding is fixed at its lowest owner; no stale response state, compatibility workaround, speculative abstraction, unresolved accepted defect, meaningful performance regression, or deferred TODO remains. +### Complete Sentry correctness, coroutine ownership, and performance + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh full-diff self-review, and independent code review are signed off. The audit covered every Sentry source/test file, `examples/sentry-laravel`, installed Sentry SDK behavior, Guzzle, Flysystem, PSR/Symfony bridges, Swoole 6.2.2 lifecycle behavior, split/root metadata, public documentation, and the connected Coroutine, Core, Object Pool, Filesystem, Cache, Notifications, Queue, Redis, DI, and Server owners. The detailed design is recorded in [`2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md`](2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md). + +| Findings | Final decision | +|---|---| +| `sentry-02`, `sentry-03`, `sentry-26` | Give each detached send exclusive ownership of one pooled SDK transport and one WaitGroup generation; keep request/job flush nonblocking, make command/graceful-worker drains bounded, flush Logs/TraceMetrics before generation capture, and reserve pool shutdown for best-effort worker exit. | +| `sentry-04` | Clone inherited mutable Hub layers and requests per coroutine, preserve placeholder scope/client and span pointers, and maintain one non-poppable authoritative root layer. | +| `sentry-05`, `sentry-06` | Propagate Guzzle headers without making an operation-local child Hub current; own response spans separately from exact per-connection nested database transaction stacks and handle null query time honestly. | +| `sentry-07`, `sentry-29` | Omit Redis parameters without PII consent, redact only the exact session key with consent, preserve key `"0"`, and share success/failure recording without erasing nullable duration semantics. | +| `sentry-08`, `sentry-27` | Catch `Throwable` at the four real feature boundaries and register one coroutine-local finalizer per exact feature/handler owner to unwind only its abandoned scopes or spans. | +| `sentry-09`–`sentry-16` | Initialize log formatter state, restore process-global error reporting, publish environment values atomically, normalize callables, preserve falsey auth fields, read view origins once, and make SDK metadata/DSN precedence truthful. | +| `sentry-17`, `sentry-30` | Restore applicable current request continuation, after-response flush, Hub, profile-sampler, metrics, monitor, health-route, and job-sampling behavior without Laravel-only integrations. | +| `sentry-18` | Port lazy Storage tracing through flat configuration transformation, exact logical disk identity, one outer decorator, complete adapter delegation, pool invalidation, signed serving, and original stream/lease ownership. | +| `sentry-19` | Start sampled publish spans only at actual enqueue attempts, carry the payload hook's resolved destination through the lifecycle, correlate exact terminals by unchanged unique payload, and add per-job sampling middleware without making publish spans Hub current. | +| `sentry-20` | Close notification spans on delivered, failed, or skipped terminals while retaining breadcrumbs on `NotificationSent`. | +| `sentry-21` | Consume exact Cache read/write/forget success and failure terminals and the owner-typed resolved many-key lists. | +| `sentry-22` | Install no hook, listener, event emission, aspect, or decorator when one merged-config capability owner proves it cannot produce spans, breadcrumbs, Spotlight output, or propagation. | +| `sentry-23` | Declare every direct split dependency and require the minimum Sentry SDK version needed for profile-sampler parity; keep optional Sanctum out of both requirements and suggestions because its inert event listener unlocks no Sentry capability. | +| `sentry-24` | Add complete Laravel-prose Sentry guidance while keeping the package README to documentation, the real bounded-transport difference, and upstream provenance. | +| `sentry-25` | Remove the synthetic HTTP client and every obsolete context key, resolver argument, Guzzle branch, special option, flush comment, and duplicated integration path superseded by the final ownership model. | +| `sentry-28` | Make the provider the sole feature register/boot failure boundary, log truthful partial effects, continue independent phases, and add no retry/rollback state. | + +- **Architecture and lifecycle ownership:** One child owns one borrowed SDK transport until release or discard. A generation swap gives each bounded drain a closed WaitGroup while later sends enter the replacement. Request and job paths only enqueue and flush nonblockingly; console and graceful queue-worker boundaries may wait for the configured positive bound; native worker exit closes acquisitions and returns immediately while Swoole's reactor gives active children its independent best-effort window. Every request coroutine owns cloned Hub layers, exact feature-local span stacks/maps, and one final flush defer. Response and per-connection transaction spans never share storage, and operation-local Guzzle/queue children never become Hub current. +- **Cross-package completion:** `coroutine-08` fixes fork callback ordering; `core-09` makes worker-exit dispatch exact; `object-pool-05` and `object-pool-06` own capability-neutral invalidation and truthful fingerprint diagnostics; `filesystem-15` and `filesystem-16` own logical-name construction/pool identity and serving capability; `cache-21` and `cache-22`, `notifications-22` and `notifications-23`, and `queue-43` through `queue-45` provide exact framework lifecycle terminals. Existing `sentry-01`, `redis-15`, and `di-02` are revalidated. Server's one-callback wiring is unchanged and revalidated; Telescope remains pending only for the `coroutine-08` callback-order consumer check. +- **Storage and public contracts:** Configured and anonymous disks keep distinct logical identities through built-in/custom/scoped construction, Sentry decorators, signed URLs, and whole-driver pools; S3/GCS client pools remain name-independent. Equivalent custom whole-driver construction may converge only through an explicit matching fingerprint. The nullable creator name and protected construction differences are documented in Filesystem, and exact `serve => true` intentionally replaces Laravel's local-driver classification with a capability contract. Decorators expose temporary-URL behavior, fluent assertions return the outer instrumented adapter, and a test-only reflection guard over both base and S3 adapter pairs prevents inherited methods from silently escaping delegation. +- **Important rejected concerns:** No worker send registry, task/result queue, retry or polling scheduler, synchronous request delivery, reflected SDK rate-limiter sharing, Hub client indirection, generic feature finalizer, dynamic feature state machine, filesystem decorator registry, process-global config swap, lazy response observer, notification failure relabeling, rollback publication event, Redis value serializer, or Laravel-only AI/Livewire/Folio/Pennant/Lighthouse/Octane integration was added. Worker-exit delivery remains explicitly best effort rather than pretending Sentry owns the server timeout. +- **Regression coverage:** Deterministic tests cover transport ownership, generation swaps, rate limits, spawn/failure cleanup, flush order and shutdown modes; fork/Hub isolation; Guzzle/database ownership; exact Cache/Notification/Queue terminals, resolved default destinations, and bulk behavior; Storage names, pools, both adapter/decorator pairs, serving, URLs, leases and capability forwarding; Redis PII/session resolution; feature gating and partial boot failures; current SDK parity; command/global-state cleanup; dynamic metadata, DSN precedence, and split package requirements. +- **Performance and complexity:** Ordinary requests and jobs gain no wait, retry, sleep, polling, lock, synchronous telemetry I/O, or request-time container resolution. An accepted send adds only the WaitGroup bookkeeping required for truthful bounded drains. Feature gating removes unusable listeners, aspects, events, and decorators. Cache/Notification/Queue starts and failures remain listener-guarded; Storage stays lazy and keeps existing pools and lease owners; Redis redaction reads already-resolved state before its guarded fallback. All maps/stacks are coroutine-local and bounded by live operations, and every terminal/finalizer removes its entry. +- **Laravel-facing result:** Supported Laravel Sentry APIs and configuration remain compatible. Additive shared framework surfaces are the pool invalidation capability, nullable Filesystem construction names, Cache retrieval-failure events, Notification skipped/delivered events, Queue enqueue-failure event, and forced-stop flag. The only lasting Laravel differences are the documented Hypervel asynchronous pooled transport and the two Filesystem construction/serving adaptations required by Swoole pooling and decorated capability ownership. +- **Validation and review:** Focused changed-owner and Sentry tests are green. The final authoritative `composer fix` changed no formatting, both PHPStan configurations passed, and the complete parallel components suite, Testbench package mode, and dogfood passed. Split metadata checks, `git diff --check`, stale-symbol scans, and a fresh caller/callee, lifecycle, API, hot-path, retained-state, dead-code, and overengineering review are complete. Independent code review verified the final queue destination, SDK capability, SQS, Hub, flush-lifecycle, metadata, and cleanup amendments and signed off with no remaining findings. +- **Assessment:** Every accepted Sentry and shared-owner defect is implemented at its lowest boundary with exact coroutine/resource ownership, bounded retained state, and no request-path blocking. Superseded transport, context, normalization, and decorator machinery is removed; no compatibility workaround, speculative registry, unbounded queue, meaningful hot-path regression, or deferred accepted defect remains. + ### Complete Inertia correctness and SSR lifecycle maintenance - **Status and inspected surface:** Maintenance implementation, focused validation, the authoritative gate, fresh self-review, and independent code review are complete. The work covered every reported Inertia finding plus related request-state, response, resolver, SSR transport, command, provider, documentation, and test surfaces. Current upstream DevTools remains the next separate Inertia work unit, so the package checklist stays open. The detailed design is recorded in [`2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md`](2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md). diff --git a/docs/plans/2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md b/docs/plans/2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md new file mode 100644 index 000000000..94e51d989 --- /dev/null +++ b/docs/plans/2026-08-08-1711-sentry-correctness-coroutine-ownership-and-performance.md @@ -0,0 +1,753 @@ +# Sentry Correctness, Coroutine Ownership, and Performance + +## Status + +Complete; implementation, authoritative validation, fresh self-review, and independent code review are signed off. + +## Scope and outcome + +Complete the Sentry audit against current Hypervel, `examples/sentry-laravel`, the installed Sentry SDK, and the long-lived Swoole runtime. Preserve the existing low-footprint design: coroutine-local Hub state, fail-fast bounded transport capacity, pooled SDK transports, cached boot decisions, sampled-span early exits, and propagation even when local recording is disabled. + +The final package keeps ordinary requests and jobs nonblocking, gives every detached send and span one exact owner, makes bounded drains truthful at process-lifecycle boundaries, installs no instrumentation that cannot produce output, restores applicable current upstream behavior, and documents the operational contract in Laravel-docs prose. Shared defects in Coroutine, Core, Object Pool, Filesystem, Cache, Notifications, and Queue are fixed at those owners rather than hidden by Sentry registries. + +References checked: + +- Hypervel components `d80d05adfb38ae8cfe5b83b609737abad95c3147`, including all Sentry source/tests and connected framework owners; +- Sentry Laravel `dbc6a5d029e9051f999f7691545c61c97b2d65be`; +- Laravel framework `8df67f9d176d1d0375a866d8c6780be95ce0336e`; +- installed `sentry/sentry` 4.30.0, Guzzle 7.15.3, Flysystem adapters, PSR interfaces, and Symfony PSR bridge; +- Swoole 6.2.2 source behavior and the focused probes below. + +The scratch audit's `sentry-01` through `sentry-29` labels are investigation-local and are not reused. Durable `sentry-01` already belongs to completed Redis work. + +This plan is the post-compaction implementation reference. It therefore reproduces the core plan's "What this audit is not" section and principles 7–10 verbatim below; principles 1–6 remain in the core audit plan. + +## What this audit is not + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Verified runtime facts + +The following Swoole 6.2.2 probes are load-bearing: + +- `OnWorkerExit` ran outside a coroutine. A child started there but did not resume while the callback remained active: `{"callback_in_coroutine":false,"child_progressed_during_callback":false,"child_finished_after_callback":true}`. The callback must not wait, poll, or sleep. +- Queue-worker normal and timer paths ran in coroutines (CIDs 1 and 3) and could wait while children progressed. +- `WaitGroup::add()` during `wait()` was rejected. A generation swap waited about 20 ms for the captured group and left one later send pending in the new group, matching WaitGroup's explicit misuse guard. +- Native worker exit repeatedly invoked its callback while reactor work remained. A two-second child completed within the default budget; a 3.2-second child was forcibly terminated. Swoole defaults are `reload_async = true` and `max_wait_time = 3`. + +These facts establish three constraints: worker-exit cleanup is best effort and nonblocking; queue-worker graceful cleanup may perform a bounded wait; and a drain must swap WaitGroup generations rather than wait a group to which later sends can add. + +## Findings and final decisions + +| ID | Category / severity | Final decision | +|---|---|---| +| `coroutine-08` | Framework defect / Major | Install copied fork context before `afterCreated` callbacks, then revalidate Sentry and Telescope. | +| `core-09` | Framework lifecycle defect / Major | Dispatch `OnWorkerExit` once per worker exit despite repeated native callbacks. | +| `object-pool-05` | Capability defect / Major | Add `InvalidatesPool::invalidatePool()` and make pool proxies expose it. | +| `object-pool-06` | Diagnostic defect / Minor | Point fingerprint conflicts at distinct identities or explicitly declared construction equivalence. | +| `filesystem-15` | Correctness/API enhancement / Major | Preserve names required by span labels and signed URLs through builds, custom creators, and scoped reconstruction, and purge through the capability contract. | +| `filesystem-16` | Serving capability defect / Major | Register signed serving routes from exact `serve => true` capability instead of discarding decorated and custom disks by concrete driver name. | +| `cache-21` | Event lifecycle defect / Major | Emit explicit one-key and many-key retrieval-failure events carrying the exception. | +| `cache-22` | Event lifecycle defect / Major | Emit existing write/forget failure terminals when store operations throw, then rethrow. | +| `notifications-22` | Event lifecycle defect / Major | Emit `NotificationSkipped` for `shouldSend()` and `NotificationSending` vetoes. | +| `notifications-23` | Lifecycle/API enhancement / Major | Emit `NotificationDelivered` immediately after the channel returns, before post-delivery callbacks. | +| `queue-43` | Lifecycle/API enhancement / Major | Expose `WorkerStopping::$terminatesImmediately` so cleanup listeners do not delay forced termination. | +| `queue-44` | Event lifecycle defect / Major | Emit `JobQueueingFailed` at the actual enqueue attempt boundary. | +| `queue-45` | Queue correctness defect / Major | Give Database/SQS bulk enqueue accurate terminals and Database bulk complete after-commit behavior without losing bulk writes. | +| `sentry-02` | Transport ownership defect / Major | Delete the synthetic HTTP client; detach only after one pooled SDK transport is exclusively owned by the child. | +| `sentry-03` | Drain/shutdown defect / Major | Use WaitGroup generations for truthful nonblocking/positive-timeout flushes and an internal worker-exit pool shutdown. | +| `sentry-04` | Hub/coroutine defect / Major | Clone inherited layer scopes/requests, preserve placeholder scope, and maintain one authoritative root layer. | +| `sentry-05` | Tracing ownership defect / Major | Keep Guzzle propagation but never install an operation-local child as Hub current; remove the dead client-config branch. | +| `sentry-06` | Tracing ownership defect / Major | Separate response spans from per-connection nested transaction spans and handle null query time honestly. | +| `sentry-07` | Privacy/correctness defect / Major | Omit Redis parameters without PII consent, redact session keys with consent, and preserve key `"0"`. | +| `sentry-08` | Failure/finalization defect / Major | Catch `Throwable` at the four real boundaries and install exact per-owner orphan cleanup. | +| `sentry-09` | Initialization defect / Minor | Initialize `LogsHandler::$batchFormatter` to null. | +| `sentry-10` | Process-global state defect / Major | Restore `error_reporting()` from TestCommand in `finally`. | +| `sentry-11` | Publication defect / Major | Publish environment values through `Env::writeVariables(..., overwrite: true)`. | +| `sentry-12` | Callable defect / Minor | Normalize model violation callbacks with `Closure::fromCallable()`. | +| `sentry-13` | I/O/type defect / Minor | Read compiled view origin once and return null on read failure. | +| `sentry-14` | Context defect / Minor | Filter only null auth context fields and preserve useful falsey identifiers. | +| `sentry-15` | Metadata defect / Minor | Use the dynamic Hypervel SDK identifier/version and a stable fallback everywhere. | +| `sentry-16` | Configuration/docs defect / Minor | Resolve the Hypervel DSN before the generic DSN and make command guidance match. | +| `sentry-17` | HTTP tracing/lifecycle defect / Major | Restore trace-continuation/current middleware parity and one correctly ordered after-response flush. | +| `sentry-18` | Storage feature gap / Major | Port lazy Storage tracing without global config mutation or breaking pool/stream ownership. | +| `sentry-19` | Queue tracing defect / Major | Start publish spans at `JobQueueing`, carry the resolved destination in the payload, consume exact terminals, and port job sampling middleware. | +| `sentry-20` | Notification tracing defect / Major | Finish delivery spans on delivered/failed/skipped terminals while retaining breadcrumbs on `NotificationSent`. | +| `sentry-21` | Cache tracing defect / Major | Consume exact read/write/forget success and failure terminals. | +| `sentry-22` | Performance defect / Major | Register no hook, listener, event emission, AOP, or decorator that cannot produce output; derive SDK capabilities from one merged-config owner. | +| `sentry-23` | Package metadata defect / Major | Declare every direct split dependency and require Sentry SDK `^4.27` in root and split metadata. | +| `sentry-24` | Documentation defect / Major | Add complete Laravel-prose Sentry guidance and keep README minimal. | +| `sentry-25` | Dead/stale design / Minor | Remove obsolete context, config, resolver, Guzzle, and temporary flush surfaces. | +| `sentry-26` | Flush-order defect / Major | Flush Logs and TraceMetrics before client drain so their envelopes enter the captured generation. | +| `sentry-27` | Orphan cleanup defect / Major | Register one local defer per owning feature/handler and unwind only its remaining scopes/spans. | +| `sentry-28` | Availability/diagnostic defect / Major | Make the provider the sole feature-phase failure boundary and log truthful partial-failure consequences. | +| `sentry-29` | Duplication/performance defect / Minor | Share Redis command success/failure recording without erasing nullable duration semantics. | +| `sentry-30` | Current parity gap / Major | Port applicable SDK Hub, profiles sampler, metrics, monitor, health-route, and continuation behavior without Laravel-only integrations. | + +## Ownership model + +| Lifetime | Owner | +|---|---| +| One Sentry HTTP send | The child coroutine and its borrowed SDK `HttpTransport` until release/discard. | +| One drain generation | The captured WaitGroup; later sends use the replacement generation. | +| Worker transport pool | `HttpPoolTransport`; only its internal worker-exit shutdown closes it. | +| Coroutine Hub layers | The active coroutine; fork callbacks clone every inherited mutable layer value. | +| One feature's scopes/spans | That feature class's coroutine-local LIFO or exact local-span map plus its one cleanup defer. | +| Response preparation spans | Tracing EventHandler's response stack. | +| Database transaction spans | Tracing EventHandler's stack keyed by exact connection identity. | +| Framework enqueue/delivery/cache lifecycle | Queue, Notifications, and Cache terminal events respectively. | +| Pooled filesystem invalidation | Any outer object implementing `InvalidatesPool`, forwarded to the actual pool owner. | + +## Implementation + +### Make detached transport ownership exact + +Delete `src/sentry/src/HttpClient/HttpClient.php`. `Pool` creates the SDK HTTP client directly, so the SDK receives real response codes and rate-limit headers. Each pooled `HttpTransport` keeps its SDK-private rate limiter. Do not copy it or use reflection: the bounded consequence is at most one extra request per pooled transport before the whole pool has learned a rate limit. + +`HttpPoolTransport::send()` borrows fail-fast, reserves the current generation, and transfers the transport to one child: + +~~~php +$transport = $this->pool->get(); + +// Cooperative scheduling and no yield between capture and add are required: +// a drain may swap generations and begin waiting immediately afterward. +$group = $this->group; +$group->add(); + +try { + Coroutine::create(function () use ($event, $group, $transport): void { + $discard = false; + + try { + $transport->send($event); + } catch (Throwable) { + $discard = true; + } finally { + try { + if ($discard) { + $this->pool->discard($transport); + } else { + $this->pool->release($transport); + } + } finally { + $group->done(); + } + } + }); +} catch (Throwable) { + try { + $this->pool->release($transport); + } finally { + $group->done(); + } + + return new Result(ResultStatus::failed()); +} + +return new Result(ResultStatus::success(), $event); +~~~ + +Pool exhaustion or closure remains the existing skipped/backpressure result. Returning the accepted Event preserves `Client::captureEvent()` EventId behavior. A coroutine-creation failure returns a failed Result after releasing the transport and balancing the generation; telemetry failure never becomes an application exception. The child logs and applies the real SDK result; only an unexpected escaping `Throwable` discards the transport. There is no worker queue, task/result registry, polling scheduler, retry path, or mutable Event snapshot. + +### Separate nonblocking flush, bounded drain, and worker shutdown + +`HttpPoolTransport::close()` never closes the pool. Null/zero is an observation: success if the active group is empty, otherwise unknown. A positive timeout swaps generations and waits only for the captured group: + +~~~php +public function close(?int $timeout = null): Result +{ + if ($timeout === null || $timeout <= 0) { + return new Result($this->group->count() === 0 + ? ResultStatus::success() + : ResultStatus::unknown()); + } + + $group = $this->group; + $this->group = new WaitGroup; + + return new Result($group->wait($timeout) + ? ResultStatus::success() + : ResultStatus::unknown()); +} +~~~ + +The shared helper flushes Logs and TraceMetrics before the client flush swaps generations: + +~~~php +public static function flushEvents(): void +{ + self::flush(null, false); +} + +public static function drainEvents(?int $timeout = null): Result +{ + return self::flush($timeout, true); +} + +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); +} +~~~ + +Both delegate to one private helper which flushes Logs, then TraceMetrics, then the client. `flushEvents()` passes no timeout and discards the transport status; ordinary request and job paths therefore never wait. Console completion and graceful queue-worker stopping call `drainEvents()` without resolving the client. The helper derives a positive bound from that client's configured HTTP timeout when no explicit timeout is supplied and returns the bounded client result. An explicit zero or negative timeout is normalized to one second; callers wanting nonblocking behavior use `flushEvents()`. A missing client returns a successful Result. Remove `flushEvents()`'s stale temporary/internal wording. + +Add an idempotent internal `HttpPoolTransport::shutdown(): void` that closes the pool only from `OnWorkerExit`. Worker exit flushes Logs and TraceMetrics, closes the pool to reject new acquisitions, and returns immediately; active child sends finish while Swoole's reactor remains alive. Final delivery is best effort and bounded by independent server `max_wait_time`. + +`WorkerStopping` gains a final additive property: + +~~~php +public function __construct( + // Existing fields... + public bool $terminatesImmediately = false, +) { +} +~~~ + +`stop()` passes false and `kill()` passes true. Its docblock states that listeners cannot start cleanup which must complete before control returns when true. Sentry performs no new flush/drain when true or when a graceful reason is `MaxMemoryExceeded`; every other graceful stop, including null reason, receives a bounded drain. Do not close the pool on this event because a programmatic queue worker may return into a still-running process. + +### Make the framework worker-exit event exactly once + +`WorkerExitCallback` owns the native repetition guard and sets it before dispatch: + +~~~php +protected bool $dispatched = false; + +public function onWorkerExit(Server $server, int $workerId): void +{ + if ($this->dispatched) { + return; + } + + $this->dispatched = true; + + try { + $this->dispatcher->dispatch(new OnWorkerExit($server, $workerId)); + } finally { + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + } +} +~~~ + +The server already resolves and registers one callback instance; its source does not change. Setting the guard before dispatch prevents replay after a throwing listener. Revalidate the server wiring and record it in the dependency index without inventing server source work. + +### Give HTTP after-response cleanup one outer owner + +Add `tracing.continue_after_response`, backed by `SENTRY_TRACE_CONTINUE_AFTER_RESPONSE` and defaulting to `true`. `FlushEventsMiddleware::handle()` registers one nonblocking coroutine defer before entering the rest of the stack. Remove its terminable method and remove flushing from the tracing defer. Register middleware in this exact order: + +~~~php +$httpKernel->prependMiddleware(TracingMiddleware::class); +$httpKernel->prependMiddleware(FlushEventsMiddleware::class); +$httpKernel->pushMiddleware(SetRequestIpMiddleware::class); +~~~ + +The second prepend makes Flush outermost and Tracing inner. Swoole's LIFO defers always put the server's later `RequestTerminated` defer before coroutine-deferred feature cleanup and the final flush. When `continue_after_response` is true, the complete order is: + +1. the server's later `RequestTerminated` defer; +2. transaction and feature orphan finalizers; +3. the one final nonblocking Logs, TraceMetrics, and client flush. + +When `continue_after_response` is false, Tracing finishes the request transaction once in terminable middleware; `RequestTerminated`, remaining feature finalizers, and the final flush then run in that order. The response is already sent before coroutine defers run, so this adds no client latency. It also covers exceptions that skip terminable middleware. + +### Correct fork ordering and Hub ownership + +Refactor Coroutine creation through one private helper so a fork installs captured context before `afterCreated` callbacks while ordinary `create()` supplies an empty context. Preserve the existing per-callback and outer exception handling: + +~~~php +private static function createWithContext(callable $callable, array $context): int +{ + $coroutine = Co::create(static function () use ($callable, $context): void { + try { + CoroutineContext::setMany($context); + + foreach (static::$afterCreatedCallbacks as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + } + + $callable(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + }); + + return $coroutine->getId(); +} +~~~ + +Document that guarantee in the coroutine guide and test it directly. Revalidate Telescope's callback because it is the other production consumer: `fork()` must retain its capture-time values, while ordinary `create()` keeps the callback's parent-propagation behavior. + +Sentry's callback prefers an already-installed fork value and otherwise reads the parent for ordinary `create()`. This fallback is intentional for selective `fork()` calls too: Sentry propagation is infrastructure context and remains present even when the caller's key list excludes its stack. The callback replaces the inherited stack with new Layers holding cloned mutable Scopes, preserving each Layer's client and each Scope's current span pointer, and clones the inherited HTTP Request. The root Hub behavior must: + +- preserve a placeholder Hub's configured Scope when binding the real client; +- clone the worker baseline Scope for every coroutine root; +- initialize one authoritative root Layer before public use; +- refuse to pop the last Layer; +- port current installed SDK 4.30 behavior while retaining coroutine-local storage; sampling-decision source/validation logging dates to 4.12 and `profiles_sampler` support to 4.27. + +Do not make worker-level `bindClient()` layer-local: that would add a lookup to every `getClient()` without a demonstrated request-level need. + +### Make Guzzle and database spans locally owned + +The Guzzle aspect always propagates trace headers when Sentry is active, even when local span recording is disabled. It captures any `http.client` child locally, never installs it as Hub current, and finishes that exact span from success/failure handling. Lower-level spans therefore no longer nest under `http.client`; this is the accepted safety trade that avoids restoring a stale parent after an asynchronous transfer. + +Delete the dead concrete-client config branch and its bound private-property closure. Guzzle merges client defaults into `$options` before the aspect observes them, so the only opt-out test is: + +~~~php +return ($options['no_sentry_aspect'] ?? false) === true; +~~~ + +Tracing EventHandler uses distinct storage: + +~~~php +/** @var list */ +$responseSpans = ...; + +/** @var array> keyed by spl_object_id($connection) */ +$transactionSpans = ...; +~~~ + +Transactions are never installed as Hub current. Queries parent to the top transaction for their exact connection, or otherwise to the current Hub span. A null `QueryExecuted::$time` creates an instantaneous span and skips duration-dependent origin collection. Nested and interleaved connections each pop their own LIFO. + +### Add exact local orphan cleanup + +`TracksPushedScopesAndSpans` registers one defer per using feature class on that class's first push in a coroutine. It unwinds only that class's remaining scopes/spans in LIFO order with aborted/internal status. Tracing EventHandler separately registers one defer for its response and connection stacks, restoring any saved response parent before coroutine exit. After finishing abandoned transaction spans, the finalizer clears the complete connection map so a later connection cannot meet a stale entry through a reused `spl_object_id`. + +Normal terminals empty the stacks, making these defers constant-time no-ops. They are a final safety boundary, not replacements for framework terminal events, which remain required for correct end time, status, and parent restoration before callers continue. The outer HTTP flush defer is registered first, so all later cleanup runs before final envelope flush. + +### Complete Cache lifecycle terminals + +Add guarded public events: + +~~~php +new KeyRetrievalFailed($storeName, $key, $exception, $tags); +new ManyKeysRetrievalFailed($storeName, $keys, $exception, $tags); +~~~ + +Use the repository's existing event helper and metadata conventions. `getRaw()` and `manyRaw()` cover both the store read and incomplete-class value normalization with the matching failure terminal, then rethrow. `manyRaw()` normalizes the complete batch before dispatching per-key success terminals so a later handler failure cannot follow an earlier partial-success sequence. Write/forget paths do the same with existing `KeyWriteFailed` and `KeyForgetFailed`. The three finite-TTL aggregate `putMany()` implementations return true before dispatch or store delegation when the values are empty, matching the read and Redis-operation guards and preventing a `WritingManyKeys` start with no per-key terminal. For nonempty batches, the aggregate store result is final before the same-outcome per-key terminals begin, so the first terminal correctly closes Sentry's batch span; do not add redundant many-write events or completion counters. New construction/dispatch work exists only on failure and only when a listener exists. + +Sentry's Cache feature consumes exact success/failure terminals. It creates no pending-operation registry and installs no listeners when neither spans nor breadcrumbs can be recorded. + +Hypervel's many-key Cache events carry resolved `list` keys from every framework emitter. Type that owner contract and consume the lists directly; delete Sentry Laravel's associative-key normalizer because Laravel emits raw `many()` input while Hypervel resolves keys before emitting. This removes a dead branch and one Collection allocation/closure from each traced cache operation. + +### Complete Notification delivery boundaries + +Add guarded events with normal notification context: + +~~~php +new NotificationSkipped($notifiable, $notification, $channel); +new NotificationDelivered($notifiable, $notification, $channel, $response); +~~~ + +`NotificationDelivered` is an owner-level lifecycle improvement: observers measuring actual channel delivery need the boundary where the channel returns, before post-delivery application callbacks. Sentry is a consumer, not the reason the boundary exists. + +`NotificationSender` preserves Laravel's order and failure meaning: + +1. `shouldSend()` false or `NotificationSending` veto dispatches Skipped; +2. the existing coroutine-local failure marker covers the complete pre-delivery phase; +3. a throwing sending listener or channel dispatches existing `NotificationFailed` unless the channel already did; +4. channel return dispatches Delivered; +5. `afterSending()` runs, then existing `NotificationSent` dispatches; +6. post-delivery errors propagate and are never relabeled as delivery failures. + +Sentry finishes spans on Delivered, Failed, or Skipped and keeps its breadcrumb on `NotificationSent`. Its local finalizer covers a terminal listener that throws before later listeners observe it. + +### Complete Queue enqueue and bulk ownership + +Move `partitionJobsByAfterCommit()` from `SqsQueue` to base `Queue` beside `shouldDispatchAfterCommit()` and rollback helpers. Add `JobQueueingFailed` carrying the same queue/job/payload/delay context as `JobQueueing` plus the exception. `enqueueNow()` owns the terminal pair: + +~~~php +$this->raiseJobQueueingEvent(...); + +try { + $jobId = $callback($this, $payload, $queue, $delay); +} catch (Throwable $exception) { + $this->raiseJobQueueingFailedEvent(..., $exception); + throw $exception; +} + +$this->raiseJobQueuedEvent(...); + +return $jobId; +~~~ + +Sentry's payload hook injects propagation data, publish time, and the resolved queue name. `JobQueueing` intentionally carries Laravel's raw queue argument, so the already-created payload is the authoritative source for the destination label; the handler falls back to the raw event value for manually constructed payloads. It starts `queue.publish` at `JobQueueing`, so a transaction rollback before enqueue creates no span or synthetic rollback event. + +Bulk Database and SQS attempts emit multiple starts before terminals, and SQS response order is not positional. Publication spans therefore use the exact unchanged event payload string as a short-lived coroutine-local key rather than the feature LIFO. `trackLocalSpan()` records the child without installing it as Hub current; `JobQueued` finishes that exact span with `ok`, and `JobQueueingFailed` finishes it with `internal_error`. The shared tracking concern's existing one feature defer also finishes and forgets any remaining local spans. Queue-created lifecycle payloads are JSON objects with unique UUIDs, while arbitrary `pushRaw()` payloads emit no lifecycle events; do not add numeric-key guards or decode payloads for correlation. + +`DatabaseQueue::bulk()` preserves both optimization and single-job semantics: + +- create payloads at dispatch time; +- partition immediate/deferred jobs by the shared policy; +- register unique/debounced rollback callbacks per deferred job; +- reacquire a pooled Queue through `afterCommitDispatcher`; +- compute delayed `available_at` at the actual insert attempt; +- emit per-job Queueing, one bulk insert per attempted group, then per-job Queued with nullable ID or Failed; +- avoid event construction when no matching listeners exist. + +A mixed batch therefore performs one immediate insert and one after-commit insert. That is the necessary transactional split. Rollback leaves no orphan database jobs. + +SQS emits starts only when each chunk is attempted. Successful entries emit Queued; explicit rejects and a thrown ambiguous request emit Failed; later unattempted chunks never start. Preparation/overflow errors close only starts already emitted. Redis, Beanstalkd, and base bulk already delegate through per-job push. + +### Port Storage tracing without breaking pooling + +Port upstream `configureDisk()` / `configureDisks()` with the same public array-returning, flat transformed configuration. Keep `sentry_disk_name`, original driver, and scoped-prefix values as siblings; nesting the original config would lose scoped-prefix expansion. + +Add the optional logical name without changing one-argument behavior or string-path normalization: + +~~~php +public function build(array|string $config, ?string $name = null): Filesystem +{ + $config = is_array($config) ? $config : [ + 'driver' => 'local', + 'root' => $config, + ]; + + return $this->resolveWithLogicalName( + $name ?? self::ON_DEMAND_DISK_NAME, + $config, + $name, + ); +} +~~~ + +Keep protected `resolve(string $name, ?array $config = null)` unchanged for configured disks and delegate it to one private `resolveWithLogicalName()` helper. `build()` enters that helper directly because an anonymous build must carry a null logical name separately from the valid configured disk name `ondemand`; it therefore no longer passes through protected `resolve()`. This is a deliberate protected-extension difference: customize on-demand construction through `Storage::extend()` or the public driver creator methods rather than overriding `resolve()`. The built-in branch uses `$name` for construction and fingerprints; the custom branch uses `$logicalName` for both. + +Pass that logical name through custom-creator calls and scoped-driver reconstruction. Built-in filesystem creators already receive the active name; the custom-creator boundary is the one construction path that drops it. `callCustomCreator(array $config, ?string $name = null)` closes that asymmetry with an additive nullable third callback argument: the configured disk name, or null for anonymous construction. Existing one-argument calls remain valid, while subclasses overriding this protected method must adopt the optional parameter. Existing two-argument creator callbacks remain valid because PHP user callbacks ignore extra arguments. `createScopedDriver(array $config, ?string $name = null)` passes the name into `build()`. The custom `sentry` creator uses the callback name when non-null and falls back to `sentry_disk_name` for anonymous construction. This is required when scoped expansion reaches a separately transformed parent whose stored name differs from the active outer name; otherwise local signed URLs use the parent's route. + +Whole-driver identity includes that name for publicly poolable built-in and custom drivers because it is construction input. S3/GCS client pools ignore the name because only the client is pooled. Custom whole-driver pools that safely ignore the name may declare equivalent construction with the same `pool.fingerprint`; a shared `pool.name` is optional but also requires that shared fingerprint when other construction input differs. + +Update the shared pool fingerprint-conflict diagnostic accordingly: recommend a distinct explicit identity, or a matching explicit fingerprint only when the differing value does not affect construction. Purging may replace one obsolete registered definition, but it cannot reconcile two live definitions that share an identity; the pool would alternate between them. The diagnostic cannot distinguish those cases, so it does not recommend purge. + +`configureDisks()` can transform both a scoped disk and its configured parent. Reconstructing that scoped disk therefore resolves an inner Sentry decorator before the outer child decorator. Name propagation already gives the inner wrapper the child's label; a marker could therefore avoid duplicate spans. The child may legitimately override the parent's span/breadcrumb flags, however, so every Sentry wrapper implements the internal `DecoratedFilesystem` contract with `getFilesystem(): Filesystem`. The creator unwraps the inner wrapper and applies one outer wrapper with the child's name and flags, the fully expanded prefix, and the original pool/stream owner. Do not inherit the parent's flags, retain nested spans, or create a generic decorator registry. + +The custom `sentry` driver reconstructs the original disk through `build($config, $logicalName)` and wraps the returned filesystem or pool proxy. It never swaps process-global config or binds a protected manager method. Logical-name propagation preserves span labels and signed local URL disk identity. Reconstructing the same flat configuration preserves pool identity, scoped prefixes, client pooling, and stream lease ownership. + +Adapter wrappers delegate temporary-URL capability checks and callback mutators to the wrapped adapter through one typed accessor; `serve()` and `serveUsing()` remain outer-owned together. Their three traced fluent assertion overrides discard the wrapped adapter's return and return the outer decorator so chains remain instrumented. Trace the four direct-I/O methods otherwise hidden by inherited base implementations: `fileExists()`, `directoryExists()`, `checksum()`, and `mimeType()`. The negative convenience methods keep composing their decorated positive operation, so no duplicate relabeling wrappers are added. Add a test-only reflection guard over non-static public adapter instance methods: each must be implemented by a decorator trait or explicitly classified as outer-owned/composition-only. Inheritance can silently shadow adapter-trait delegation; contract-only wrappers remain compile-time complete for their declared contracts, while concrete-only dynamic helpers do not justify a generic identity-rewriting proxy. + +Add the shared capability: + +~~~php +interface InvalidatesPool +{ + public function invalidatePool(): bool; +} +~~~ + +`PoolProxy` and `ClientPooledFilesystem` implement it. Every Sentry filesystem wrapper exposes the capability once through its shared implementation and forwards only when the unwrapped filesystem also implements it; otherwise it returns false, meaning no pool was removed. + +`FilesystemManager::purge()` checks the contract instead of concrete types. When no cached disk exists but a configured driver does, resolve that exact configured disk and invalidate through the same capability: + +~~~php +if ($disk === null && ! empty($config['driver'])) { + $disk = $this->resolve($name, $config); +} + +if ($disk instanceof InvalidatesPool) { + $disk->invalidatePool(); +} +~~~ + +This deletes the second implementation of pool-identity derivation, supports transparent userland wrappers without manager knowledge, and preserves distinct configured and on-demand whole-driver identities. Resolving framework pooled drivers creates only lazy proxies and performs no pool/client I/O; built-in non-pooled drivers construct their filesystem stack; custom non-pooled creators may run. This is acceptable on the boot/test/operational-recovery purge path. Invalid configured drivers and throwing creators now propagate just as normal disk resolution does; unconfigured or driverless names remain no-ops. + +Document the third custom-creator argument in the filesystem guide and its whole-driver fingerprint effect in the pools guide. Record the additive creator signature under the filesystem README's `Differences From Laravel`; do not generalize it to managers whose built-in creators do not consume a logical name. + +Make filesystem route registration capability-based: exact boolean `serve => true` registers the signed download/upload routes for any configured disk, while false or absent values register nothing. This intentionally stops accepting truthy non-booleans and differs from Laravel's local-only gate. Every shipped disk already provides the serving surface used by `ServeFile` / `ReceiveFile`; custom opted-in drivers must do the same. Record the lasting difference in the filesystem README and update the guide heading/anchor rather than retaining local-only wording. Do not resolve disks, inspect effective/original drivers, or add Sentry-specific route code during boot. + +If neither spans nor breadcrumbs are enabled, the custom driver returns the original filesystem directly. + +Cover the applicable filesystem contract, Cloud URL operations, `readStreamRange`, eager temporary URLs, and purge. Do not add a generic disk-decorator registry or machinery for lazy streamed-response callbacks whose terminal is outside this integration. + +### Make Redis PII and session resolution safe + +Redis spans/breadcrumbs omit command parameters unless `send_default_pii` is true. With PII enabled, parameters remain observable but the current session key is redacted. Preserve `"0"` by filtering only null/absence, never PHP truthiness. + +Redis command parameters are `array`. Resolve the session key once per command, replace only matching string parameters with one shared placeholder, and pass non-string parameters—including null—through unchanged so a missing session key cannot redact a null Redis argument. + +Extract one shared Cache/Redis session-key concern with this resolution order: + +1. use the already-resolved session store when available; +2. use the current request's session cookie when available; +3. only as a last resort resolve/build the store under a coroutine-local reentry guard; +4. catch `Throwable` and return no key when session resolution itself fails. + +The guard exists only around last-resort construction; ordinary resolved-store/cookie paths allocate no context state. Tighten `replaceSessionKey(string $value)` and remove its unreachable nullable branch. + +Extract the duplicated Redis success/failure recorder while preserving distinct nullable-duration behavior. Keep Redis pool metrics and no-op early exits intact. + +### Gate instrumentation at worker boot + +Use merged boot configuration because Guzzle AOP registration must precede proxy generation. Do not register the global Guzzle aspect or child-Hub clone hook when neither DSN nor Spotlight is active. + +Treat Spotlight as active when configured with exact `true` or a non-empty URL string, and read `SENTRY_SPOTLIGHT` in the shipped config. Do not duplicate the SDK's URL validator. + +One small `SdkCapabilities` service owns the merged-config rules for an active endpoint, SDK tracing, and breadcrumbs. Feature instances resolve that auto-singleton through their existing container and retain their per-feature boolean memoization; the Guzzle aspect receives it through constructor injection; and the provider's protected DSN/Spotlight extension seams delegate to it. This removes duplicate array/dot readers without changing Feature's constructor contract or adding request-path resolution. + +Cache two independent booleans: + +~~~php +$canRecordSpans = $traceFeatureEnabled && $sdkTracingEnabled; +$canRecordBreadcrumbs = $breadcrumbFeatureEnabled && $maxBreadcrumbs > 0; +~~~ + +Use them to skip unusable database/view/routing/scheduled hooks, Redis event emission, Cache/Notification listeners, and Storage decorators. Propagation remains independent: incoming trace continuation, outbound Guzzle headers, and queue payload trace data still work when local spans are disabled. + +Derive these two booleans from merged config because Storage may resolve before the Hub is built. Mirror both SDK tracing rules: `enable_tracing === true` implies the SDK's default sample rate, otherwise tracing requires `enable_tracing !== false` and a non-null `traces_sample_rate` or `traces_sampler`. Use `Options::DEFAULT_MAX_BREADCRUMBS` for the breadcrumb default. Keep PII decisions Hub-derived because they have no pre-boot reader. Cap per-disk Storage overrides by these global capabilities. + +`Feature::boot()` and `bootInactive()` stop swallowing errors. Remove redundant explicit singleton bindings because concrete features already auto-singleton and existing instance swaps must survive. The provider catches each register/boot phase once and logs a structured warning with feature class, phase, and exception. State that the phase failed and did not complete, effects applied before the throw remain, and the phase is not retried for the worker lifetime. A successful ConsoleScheduling register followed by a failed boot therefore leaves its macro installed. Continue later phases independently; do not report through Sentry or add rollback, retry, or disable state. + +Revalidate `di-02`: inactive Sentry must not force proxy generation; no DI source change is expected. + +### Restore current applicable parity + +Implement the following without Laravel-only integration code: + +- dynamic version and identifier `sentry.php.hypervel` in provider, pool, About, and Test surfaces, with a stable fallback; +- DSN precedence `SENTRY_HYPERVEL_DSN`, then `SENTRY_DSN`, and matching Publish/Test guidance; +- `continue_after_response`: deferred finalization when true, one terminate-phase finalization when false; +- always call `continueTrace()` for propagation; start no transaction when SDK tracing is disabled; +- per-job `SentryTracesSampleRate` middleware; +- scheduled monitor expression override; +- `enable_metrics` and default `/up` ignore behavior; +- current SDK Hub behavior: sampling-decision source/validation logging introduced in 4.12 and `profiles_sampler` introduced in 4.27; +- About output recognizing a custom profile sampler. + +Keep monitor/job propagation independent of local span recording. Do not port Laravel-only AI, Livewire, Folio, Pennant, Lighthouse, or Octane integrations. + +### Complete focused correctness cleanup + +- Initialize `LogsHandler::$batchFormatter = null`. +- Catch `Throwable` in general EventHandler, Tracing EventHandler, Redis session resolution, and Cache session resolution. +- Restore `error_reporting()` in TestCommand with `try/finally`. +- Use `Env::writeVariables(..., overwrite: true)` in PublishCommand. +- Normalize model violation callables with `Closure::fromCallable()`. +- Read a compiled view origin once and treat false as null. +- Preserve falsey auth IDs/fields by filtering only null. +- Encode queue payload data once; if `json_encode()` returns false for a custom Job's non-encodable data, publish a null body-size attribute instead of throwing from instrumentation. +- Check scope count before flushing in `maybePopScope()` so a first/no-scope job does no work. + +Remove the unused Hub context-ID constant, redundant `breadcrumbs.sql_bindings` special option, no-op `tracing.default_integrations` and unused resolver argument, duplicate view wording, dead concrete Guzzle client branch, and temporary flush comments. + +### Correct package metadata and optional integrations + +Declare direct split requirements for Guzzle, Monolog, Nyholm PSR-7, PSR HTTP message/log, Symfony PSR bridge, Hypervel Filesystem, and Hypervel Validation. Set `sentry/sentry` to `^4.27` in both root and split metadata because profiles-sampler parity requires 4.27; do not raise to the installed version without an API reason. + +Sanctum stays out of split requirements and suggestions. Keep its event class string registered without `class_exists()`: the `::class` expression is a free literal, while the guard would force an unnecessary autoload attempt at boot. The listener is inert when Sanctum is absent, and installing Sanctum does not unlock a Sentry capability that warrants a suggestion. + +### Document the supported operating contract + +Create `src/boost/docs/sentry.md` in Laravel-docs prose and add it to the documentation index. Cover installation, DSN precedence, errors, logs, tracing and sampling, metrics, queue sampling middleware, monitors, Storage, Redis/cache PII, Spotlight, transport pooling/backpressure, and shutdown. + +State accurately: + +- normal request/job capture is detached and nonblocking; +- pool exhaustion drops telemetry rather than blocking application work; +- graceful queue-worker and command drains are bounded; +- worker-exit final delivery is best effort; +- `server.settings.max_wait_time` should be strictly greater than `SENTRY_HTTP_TIMEOUT`, with margin for other shutdown work; +- the two timeouts remain independently configured. + +Keep `src/sentry/README.md` minimal: documentation link, the genuine bounded asynchronous-transport difference, and upstream reference. The Storage configuration methods are upstream parity and belong only in the guide. + +## Affected source and documentation + +Primary Sentry files: + +- `src/sentry/src/Transport/{HttpPoolTransport,Pool}.php`; delete `src/sentry/src/HttpClient/HttpClient.php`; +- `src/sentry/src/{Hub,Integration,SdkCapabilities,SentryServiceProvider,EventHandler,Version}.php`; +- `src/sentry/src/Aspects/GuzzleHttpClientAspect.php`; +- `src/sentry/src/Tracing/{EventHandler,Middleware,ViewEngineDecorator}.php`; +- `src/sentry/src/Features/{CacheFeature,ConsoleIntegration,ConsoleSchedulingFeature,NotificationsFeature,QueueFeature,RedisFeature}.php`; +- `src/sentry/src/Features/Concerns/{ResolvesEventOrigin,TracksPushedScopesAndSpans,WorksWithSpans}.php` plus one shared session-key concern; +- `src/sentry/src/Http/FlushEventsMiddleware.php`; +- `src/sentry/src/Logs/LogsHandler.php`; +- `src/sentry/src/Console/{AboutCommandIntegration,PublishCommand,TestCommand}.php`; +- new Storage feature/decorator files following the upstream feature shape, including the internal `DecoratedFilesystem` unwrapping contract; +- `src/sentry/config/sentry.php`, root/split `composer.json`, `src/sentry/README.md`, `src/boost/docs/sentry.md`, and the docs index. + +Framework-owner files: + +- `src/coroutine/src/Coroutine.php` and `src/boost/docs/coroutines.md`; +- `src/core/src/Bootstrap/WorkerExitCallback.php`; +- `src/object-pool/src/Contracts/InvalidatesPool.php`, `src/object-pool/src/PoolProxy.php`, and `src/object-pool/src/PoolManager.php`; +- `src/filesystem/src/{ClientPooledFilesystem,FilesystemManager,FilesystemServiceProvider}.php`, `src/filesystem/README.md`, `src/boost/docs/filesystem.md`, and `src/boost/docs/pools.md`; +- Cache events, `src/cache/src/Repository.php`, same-family tagged paths shown by source tracing, and `src/boost/docs/cache.md`; +- Notifications events, `src/notifications/src/NotificationSender.php`, and `src/boost/docs/notifications.md`; +- Queue events, `src/queue/src/{Queue,DatabaseQueue,SqsQueue,Worker}.php`, and `src/boost/docs/queues.md`. + +`src/server` is revalidated but unchanged. Add or update focused tests under each owning package; do not hide framework-owner coverage inside Sentry-only tests. + +## Testing plan + +### Transport and shutdown + +- accepted sends retain EventId, exclusively own one transport, and release after real completion; +- pool exhaustion/closure skips without blocking; unexpected child failure discards; spawn failure balances the generation, releases, and returns a failed Result without throwing into application code; +- real status/rate-limit headers affect the next send on the same transport; +- zero close reports success/pending without waiting; positive close captures one generation and leaves later sends in the next; +- Logs/TraceMetrics envelopes are created before drain generation capture; +- queue graceful drain waits; immediate/max-memory stops do not flush or wait; +- repeated native WorkerExit dispatches one framework event/resume; a throwing listener is not replayed; +- OnWorkerExit flushes once, shuts down once, returns without waiting, and rejects later acquisitions. + +### Coroutine, Hub, and spans + +- fork copied context is visible before callbacks; create behavior remains stable; Sentry/Telescope callback results survive; +- parent/child/sibling Hub Scope and Request objects are isolated while client/span pointers remain correct; +- placeholder scope survives client binding; every root clones its baseline; the final root cannot pop; +- Guzzle propagation survives disabled spans; its child never becomes Hub current; the exact child finishes on success/error; +- nested/interleaved database connections pop only their own spans; response stacks remain separate; null query time is instantaneous; +- caught missing-terminal paths are locally unwound at coroutine end, while normal paths leave finalizers as no-ops. + +### Framework terminal owners + +- Cache one/many store-read or unserializable-class-handler throws emit one failure terminal and rethrow; a many-read handler failure emits no partial Hit/Missed terminals; write/forget throws use existing failures; no event construction without listeners; +- empty finite-TTL batch writes return true without dispatching `WritingManyKeys` or touching the store in Repository, Redis all-tag, and Redis any-tag paths; +- Notification skipped/delivered/failed/sent order is exact; pre-delivery dedup holds; post-delivery errors propagate without false failure; +- Queue single enqueue emits Queueing then Queued/Failed; rollback before enqueue emits nothing; +- Database bulk preserves one insert per attempted group, after-commit delay origin, rollback behavior, reacquisition, and exact per-job terminals; +- SQS partial/failed chunks terminalize only attempted entries with the unchanged payload; later chunks never start; +- publication spans never become Hub current; a defaulted queue keeps the same resolved destination on publish and process spans, and three starts followed by mixed out-of-order success/failure terminals finish the exact keyed spans; +- forced queue termination exposes `terminatesImmediately` and is never delayed by telemetry. + +### Storage, PII, gating, and parity + +- string-path/on-demand builds and existing two-argument custom creators retain their contracts; +- custom creators receive null for anonymous `build()`, the literal configured name for `disk('ondemand')`, and the explicit name for `build($config, 'uploads')`; anonymous and configured custom pool identities remain distinct; +- scoped local disks generate signed routes for the outer configured name, and transformed Sentry scoped disks preserve the same route; +- custom creators receive the configured nullable name; custom poolable identities split by name unless explicit pool controls declare equivalence; +- S3/GCS client pools remain name-independent; built-in whole-driver pools already include names; custom whole-driver pools now include names and require a shared explicit fingerprint to converge safely across them; +- pool fingerprint conflicts explain both valid remedies, with the explicit-fingerprint path qualified by construction equivalence; +- flat configured disks remain lazy and preserve logical identity, scope prefix, URLs, Cloud behavior, `readStreamRange`, temporary URLs, temporary-URL capability checks and callback mutators, leases, and purge invalidation; +- serve-enabled and ordinary local disks report true and false temporary-URL capability respectively; the adapter ownership guard classifies every non-static public method on both base and S3 adapter/decorator pairs; +- `fileExists`, `directoryExists`, `checksum`, and `mimeType` each emit the expected span/breadcrumb without adding wrappers for composed negative operations; +- all three traced fluent assertions return the outer decorator and a following chained operation remains instrumented; +- configured nested/scoped disks use the outer logical name and feature flags with exactly one Sentry decorator; +- a forgotten configured scoped pooled disk resolves its configured identity and removes that pool; +- named and anonymous scoped whole-driver builds keep distinct identities, and purging the named disk leaves the anonymous pool alone; +- transformed Sentry disks forward purge invalidation after `forgetDisk()`; non-pooled decorators return false; +- purge remains a no-op for driverless names and throws for an unsupported configured driver; +- no-op Storage returns the original disk; +- exact `serve => true` controls route registration for local and custom/decorated disks; an opted-in non-local disk serves through the registered route, while absent/false values register nothing; +- a transformed served local disk keeps its outer logical route, serves successfully, and emits eager `file.mimeType` telemetry; +- Redis PII disabled/enabled/redacted cases, key `"0"`, resolved-store, cookie, last-resort recursion, and Throwable behavior; +- inactive provider installs no unusable listeners/AOP/events/decorators; Spotlight URL configuration is active; span/breadcrumb decisions remain independent; `enable_tracing => true` and a zero trace rate mirror SDK semantics; propagation remains active; +- feature-phase failures log their exact partial-effect and no-retry consequence once without overwriting pre-registered feature instances; +- dynamic SDK metadata, DSN precedence, both `continue_after_response` finalization modes, continuation, profiles sampler, metrics, the conventional health route path, monitor expression, and job middleware; +- split metadata leaves optional Sanctum integration unlisted without forcing its event class to autoload at boot; +- Logs formatter default, error-report restoration, Env publication, callable normalization, falsey auth, view-origin failure, non-encodable custom-job data producing a null queue body size, and first-job no-flush behavior. + +Run each changed test file immediately, then all Sentry and connected framework tests, relevant filesystem/queue integrations, and finally `composer fix`. + +## Performance, scalability, and compatibility + +- Request and ordinary job paths add no wait, retry, sleep, polling, lock, container resolution, or synchronous Sentry network work. +- A captured WaitGroup reference plus `add()`/`done()` surrounds only an actual Sentry network send; this bounded integer bookkeeping replaces unsafe coroutine-context transport tracking. +- Queue publication span correlation adds one short-lived coroutine-local map entry only for a sampled publish attempt; it avoids Hub mutation, payload decoding, and positional/LIFO mismatches. +- Feature gating removes listeners, event construction, Redis event emission, decorators, and AOP work when output is impossible. +- Cache/Notification/Queue event additions are `hasListeners()`-guarded; new work is on accepted operations or cold failure paths. +- Database bulk retains bulk inserts. A mixed immediate/deferred batch necessarily uses two inserts to preserve transaction correctness. +- Storage stays lazy and preserves existing pools rather than constructing disks at boot or bypassing lease ownership. Uncached purge may reconstruct a configured non-pooled disk solely to discover its invalidation capability; it performs no client/pool I/O for framework pooled drivers and is outside request hot paths. +- Serving-route registration reads one existing boolean config value at boot and never resolves a disk. Non-local/custom disks participate only when explicitly configured with exact `serve => true`; this intentional capability contract differs from Laravel's local-only gate. +- Name-aware custom poolable drivers split pools by logical name for correctness. A shared explicit fingerprint preserves cross-name convergence when the author knows name-independent reuse is safe; an optional shared explicit name may choose the identity but does not replace fingerprint compatibility. Existing `pool.name`-only custom configurations with differing names now fail rather than unsafely share. No request-operation work is added. +- Redis/session redaction fast paths use already-resolved state before fallback resolution. +- Public Laravel-facing behavior remains compatible except for the documented Filesystem protected construction seams required to preserve anonymous logical identity. Additive framework surfaces are `FilesystemManager::build(..., ?string $name)`, `createScopedDriver(..., ?string $name)`, `callCustomCreator(array $config, ?string $name = null)`, the nullable logical-name argument supplied to custom filesystem creators, `InvalidatesPool`, Cache retrieval-failure events, Notification skipped/delivered events, Queue failure event, and `WorkerStopping::$terminatesImmediately`. Existing one-argument `callCustomCreator()` calls remain valid, but overrides must adopt the optional parameter; `resolve()` remains the configured-disk seam and no longer intercepts `build()`. These surfaces solve verified general owner defects without hidden compatibility machinery. +- The accepted Guzzle trace-tree change prevents corrupt parent restoration; lower-layer spans no longer appear beneath `http.client`. +- Final worker-exit delivery is deliberately best effort, not a false guarantee or a reason to couple Sentry and server timeout configuration. + +## Rejected concerns and machinery + +- No worker-global pending-send/task/result registry, transport queue, retry system, poll scheduler, or copied Event snapshot. +- No synchronous request-path Sentry delivery or worker-pool closure from `TransportInterface::close()`. +- No private SDK rate-limiter sharing through reflection or copied internals. +- No wrapper/fork of SDK Logs/Metrics runtime managers; envelopes snapshot attribution, and early cross-request flush does not misattribute them. +- No layer-local worker Hub client binding without a demonstrated request-level consumer. +- No model/property-cache redesign without evidence. +- No generic filesystem decorator registry, process-global config swap, bound protected manager call, or lazy streamed-response observer. +- No reflection/arity registry or poolable-only callback signature for filesystem custom creators; both would make construction identity depend on callback shape or registration mode. +- No failed-publication event for transaction rollback before enqueue because no attempt began. +- No relabeling of post-delivery notification errors as delivery failures and no reordering of Laravel's `afterSending()` / `NotificationSent` lifecycle. +- No dynamic feature toggles, feature retry/disable registries, or feature-registration reporting through Sentry. +- No Laravel-only AI, Livewire, Folio, Pennant, Lighthouse, or Octane integrations. +- Existing scheduled-task failure handling already checks exit status and is not reopened. + +## Records and completion + +During implementation, replace pending plan wording with the final design rather than appending decision history. After code review: + +- add one compact ledger work unit for the Sentry audit and allocate the durable IDs above; +- revalidate existing `sentry-01`, `redis-15`, and `di-02`; +- reopen and amend the completed Coroutine, Core, Object Pool, Filesystem, Cache, Notifications, and Queue entries for every shared owner finding above, then revalidate their named consumers; +- record `filesystem-16` as the exact serving-capability predicate and its intentional Laravel difference; +- record that filesystem built-in and custom creators receive the active logical name, so whole-driver pool identity includes it; a shared explicit fingerprint may declare safe convergence, while S3/GCS client pools ignore the name; +- record the custom `pool.name`-only behavior change and the corrected shared fingerprint-conflict remedy; +- record that this closes a filesystem-specific built-in/custom construction asymmetry and must not be copied mechanically to managers whose built-in creators have no logical-name input; +- add dependency-index rows for every shared owner finding, including Core → Sentry, Server as an explicitly revalidated unchanged callback-wiring consumer, and the pending Telescope package as a revalidated consumer of `coroutine-08` without reopening Telescope; +- update the routing index to the exact Sentry work-unit heading and required cross-package entries; +- check off Sentry only after implementation, all gates, fresh self-review, independent code review, owner approval, and the final bookkeeping commit; +- state that public Laravel APIs/configuration remain compatible and list the additive general framework surfaces above. + +No accepted defect, TODO, compatibility path, or deferred implementation remains in this work unit after completion.