diff --git a/composer.json b/composer.json index 52bd58207e..759900944b 100644 --- a/composer.json +++ b/composer.json @@ -80,7 +80,7 @@ "open-telemetry/context": "^1.5", "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/sdk": "^1.15", - "open-telemetry/sem-conv": "^1.38", + "open-telemetry/sem-conv": "^1.45", "paragonie/constant_time_encoding": "^3.1", "phpoption/phpoption": "^1.9", "phpstan/phpdoc-parser": "^2.3", diff --git a/docs/plans/2026-10-22-0911-framework-correctness-improvements.md b/docs/plans/2026-10-22-0911-framework-correctness-improvements.md index 723bd9f2a2..940715e174 100644 --- a/docs/plans/2026-10-22-0911-framework-correctness-improvements.md +++ b/docs/plans/2026-10-22-0911-framework-correctness-improvements.md @@ -65,6 +65,18 @@ The 0.4 integration preserves both sets of supported behavior. Record further fr - Preserve chained cancellation through model pruning callbacks and managed connection destruction. Pruning stops without reporting cancellation; discarded connections still release pool capacity before rethrowing it. - Extend existing cancellation tests with chained exceptions at query, transaction callback and cleanup boundaries. Preserve public signatures and ordinary-error behavior. Validate the framework and affected database consumers together. +### Authentication headers on redirects + +- Remove Saloon authentication headers and MCP custom headers once a redirect leaves their configured origin, keeping them removed for that chain even if it returns to the original origin. Preserve redirects, absolute endpoints and application-owned PSR hooks; a new Saloon retry attempt or MCP operation applies authentication again. Guzzle already keeps Authorization removed after cross-origin redirects, but the upstream packages forward other credential headers. +- Record header names written through PendingRequest's existing header methods while an authenticator runs, including unchanged values and nested authentication. Keep this state on the pending request and restore the previous recording flag in `finally`. In Sender's existing per-request middleware, remove those headers for another scheme, host or port before PSR hooks and observers run. Ordinary custom headers retain their existing behavior; no opt-in authentication setter, generated-consumer changes or shared middleware API. +- Keep MCP's existing middleware and its list of custom headers. Its closure-local state belongs to one pending request's redirect chain; both HTTP transport operations and OAuth advertisement probes create a fresh request. Preserve MCP protocol/session headers and test credential removal on return hops alongside authentication on a fresh operation. +- Cover physical redirected requests and the response's recorded request, same-origin and cross-origin destinations, absolute endpoints, PSR hook ordering, an unauthenticated return to the original origin and restored authentication on a new retry attempt. Retain concise rationale beside the implementation. + +### Cancellation during coroutine error reporting + +- Propagate cancellation chains from exception-handler callbacks before the PHP error-log fallback. Reporting may suspend even though startup hooks themselves must not. Do not broaden the startup-hook contract or add guards for suspending hooks. +- Contain cancellation from reporting at the terminal coroutine boundary, while continuing to report task cleanup failures that carry cancellation. Preserve ordinary startup-error continuation and native-only fallback checks. Extend the owned-startup reporting test with real cancellation and throwing cleanup; cover terminal reporting cancellation separately. + ## Verification - Reuse the existing database version/override/PDO-reset and queue-hook cases; add the additive builder grammar/unsupported-path checks and real multi-connection lock contention coverage. diff --git a/docs/todo.md b/docs/todo.md index 8a3cfeef54..03bbc57eba 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -30,7 +30,6 @@ - Investigate where requiring and directly using a PHP extension would make framework code significantly faster than its current pure-PHP implementation. The framework already declares bundled extensions it depends on, so the question is which hot paths are doing in PHP what a C extension does natively. The worked example is `ext-gmp` for identifier encoding: UUID and ULID string conversion and any base32/base58/base62 short-id work exceed 64 bits, so `ramsey/uuid` and `symfony/uid` convert them digit by digit in PHP, while `gmp_init()`/`gmp_strval()` do arbitrary-base conversion natively — a hand-rolled base-36 UUID conversion measured 14.6 µs against 0.5 µs for the GMP equivalent with byte-identical output. Anything that fits in a 64-bit int (snowflakes, timestamps, counters) needs no extension, and hashing, encryption, and signatures are already C. Measure `Str::uuid()`/`Str::ulid()` and the other candidates before adding a requirement, and weigh each new extension against installation cost. - Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule. - Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. -- Add PHPStan Eloquent extensions that preserve `Eloquent\Builder` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations. - Create a `hypervel/phpstan` package of PHPStan rules for Hypervel's coroutine and worker-lifetime patterns, so mistakes no longer depend on agent steering and review alone. The rules check how code is written rather than one package's API, so the framework, applications, private packages and third-party packages must all be able to use them. Make it a development-only split package modeled on Larastan, with its own split repository and Packagist entry: require the supported `phpstan/phpstan` range and register the rules in an `extension.neon` listed under `extra.phpstan.includes`, so `phpstan/extension-installer` loads them after `composer require --dev hypervel/phpstan`. Include it from Components' own `phpstan.neon.dist`. Keep package-owned type extensions, such as those in Database, Foundation, Head and Scout, with their packages: they describe those packages' APIs and must load whenever the package is installed, while these rules report errors in the consumer's own code and must be opted into. Test each rule with PHPStan's `RuleTestCase` and fixtures covering both defects and valid code. - Start with cancellation checks that miss cleanup exception chains around callbacks, listeners, framework I/O and `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Detect a `catch (CanceledException)` that rethrows followed by a broader catch that reports, falls back, records an outcome or retries; `instanceof CanceledException` on a throwable other than one just caught as that type; `getPrevious() instanceof CanceledException`; and failure collectors such as `$failure ??= $exception` that should use `Coroutine::preferCancellation()`. Exempt Swoole and Hypervel native waits (channels, `WaitGroup`, `Coroutine::join()` and `Waiter`) only when they are the only work in the `try` block; other valid exact checks use `@phpstan-ignore` with a reason. Use the cancellation regression cases to assess detection and false positives, and preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Typed domain catches that can receive a wrapped cancellation, failure observers dispatched before a rethrow and ownership decisions remain review responsibilities rather than being inferred automatically. - Then turn the coroutine-safety and worker-lifetime rules in `AGENTS.md` into checks. PHPStan rules for detection with type information belong in this package; consider Rector in dry-run mode or another linter only for checks PHPStan cannot express. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`. diff --git a/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php index 8268a77a76..c25812c25b 100644 --- a/src/broadcasting/src/Broadcasters/Broadcaster.php +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -120,6 +120,14 @@ public static function formatChannelsUsing(?Closure $callback): void static::$channelFormatter = $callback; } + /** + * Get the registered outgoing channel formatter. + */ + public static function channelFormatter(): ?Closure + { + return static::$channelFormatter; + } + /** * Register the incoming channel authorizer. * diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index d2d57e2cd5..29de68ffbf 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -181,8 +181,6 @@ private static function runChild(callable $callable, array $context): void foreach (static::$afterCreatedCallbacks as $callback) { try { $callback(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { static::printLog($throwable); } @@ -294,7 +292,7 @@ public static function reportExceptions(bool $report = true): void /** * Report an exception through the exception handler. * - * @throws CanceledException When the failure or reporting path is canceled + * @throws Throwable When cancellation or its cleanup failure must propagate */ protected static function printLog(Throwable $throwable): void { @@ -313,9 +311,11 @@ protected static function printLog(Throwable $throwable): void $container->make(ExceptionHandlerContract::class) ->report($throwable); } - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (static::causedByCancellation($exception)) { + throw $exception; + } + try { error_log((string) $throwable); } catch (CanceledException $exception) { @@ -336,7 +336,10 @@ protected static function reportUncaught(Throwable $throwable): void try { static::printLog($throwable); - } catch (CanceledException) { + } catch (Throwable $exception) { + if (! static::causedByCancellation($exception)) { + throw $exception; + } } } diff --git a/src/data/README.md b/src/data/README.md index b2f0620971..e45f8024dc 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -46,7 +46,7 @@ Spatie's data-specific `From*` attributes are not included. Use Hypervel's conte With `withoutOptionalValues()`, a missing `Optional` property that cannot hold `null` keeps `Optional`, where Spatie leaves it uninitialized. A missing nullable `Optional` property receives `null`, as in Spatie. -Spatie's `UnserializeCast` is not included because it unserializes property input, which may come from a request, without restricting the classes it creates. Write a custom cast that passes `allowed_classes` to `unserialize()` when a property holds trusted serialized values. +`UnserializeCast` restores no objects unless their classes are listed in `allowedClasses`, where Spatie allows every class by default. List the classes your trusted serialized values contain, including nested ones, or pass `true`. See [unserializing values](https://hypervel.org/docs/data-objects#unserializing-values). Named `collect*` methods receive the source's own array, collection, or paginator shape after its values have been converted to data objects, rather than the original source values. An Eloquent collection source is provided as a base `Hypervel\Support\Collection`. When you pass an explicit `$into` target, the method's declared return type must also match that target. diff --git a/src/data/composer.json b/src/data/composer.json index 4b98d2b124..249dae8055 100644 --- a/src/data/composer.json +++ b/src/data/composer.json @@ -36,6 +36,7 @@ "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", diff --git a/src/data/src/Casts/Cast.php b/src/data/src/Casts/Cast.php index 2271dbf642..e9f0eca32c 100644 --- a/src/data/src/Casts/Cast.php +++ b/src/data/src/Casts/Cast.php @@ -7,7 +7,6 @@ use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; -// REMOVED: UnserializeCast accepted serialized request input; use a custom Cast for trusted formats. interface Cast { /** diff --git a/src/data/src/Casts/UnserializeCast.php b/src/data/src/Casts/UnserializeCast.php new file mode 100644 index 0000000000..25a53f4392 --- /dev/null +++ b/src/data/src/Casts/UnserializeCast.php @@ -0,0 +1,48 @@ +|bool $allowedClasses + */ + public function __construct( + private readonly bool $failSilently = false, + // Spatie allows every class by default. Hypervel restores no objects unless their + // classes are allowed, so unserializing runs no __wakeup() or __destruct() by default. + private readonly array|bool $allowedClasses = false, + ) { + } + + /** + * Cast a property value by unserializing it. + */ + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed + { + /** @var ?string $value */ + if ($value === null) { + return null; + } + + try { + return unserialize($value, ['allowed_classes' => $this->allowedClasses]); + } catch (Throwable $e) { + // The restore hooks of allowed classes can suspend, so cancellation is not a failed cast. + if ($this->failSilently && ! Coroutine::causedByCancellation($e)) { + return Uncastable::create(); + } + + throw $e; + } + } +} diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 6d3004144e..87c3378ec7 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -22,6 +22,7 @@ - [Abstract Data Objects](#abstract-data-objects) - [Backed Enums](#backed-enums) - [Casts and Transformers](#casts-and-transformers) + - [Unserializing Values](#unserializing-values) - [Custom Casts](#custom-casts) - [Custom Transformers](#custom-transformers) - [Global Casts and Transformers](#global-casts-and-transformers) @@ -898,6 +899,38 @@ class InvoiceData extends Data } ``` + +### Unserializing Values + +The `UnserializeCast` runs PHP's `unserialize` function on a value. This is useful when you store serialized values yourself, for example in a database column or a cache: + +```php +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Casts\UnserializeCast; + +class SongData extends Data +{ + public function __construct( + public string $title, + #[WithCast(UnserializeCast::class, allowedClasses: [SongMetadata::class])] + public SongMetadata $metadata, + ) { + } +} +``` + +The `allowedClasses` argument lists the classes that may be created while unserializing. Any other class becomes a `__PHP_Incomplete_Class` object, so a property typed as that class fails with a `TypeError`. This also applies to nested objects: when `SongMetadata` holds a `CarbonImmutable` date, `CarbonImmutable` must be listed as well. By default, no classes are allowed, so only scalar values, arrays, and enum cases are restored. You may pass `true` to allow every class. + +> [!WARNING] +> Only unserialize trusted data. Class restrictions do not make user-controlled serialized data safe. For user input, use JSON and validate the decoded values. + +When the value cannot be unserialized, an exception is thrown. You may make the cast fail silently instead, which leaves the value to the built-in conversion: + +```php +#[WithCast(UnserializeCast::class, failSilently: true, allowedClasses: [SongMetadata::class])] +public SongMetadata $metadata; +``` + ### Custom Casts diff --git a/src/docs/inertia.md b/src/docs/inertia.md index 60cdb8e80c..e3433f208f 100644 --- a/src/docs/inertia.md +++ b/src/docs/inertia.md @@ -179,6 +179,17 @@ Version labels refer to the Inertia client-side libraries. Features marked with - [Conditional Once Props](#once-props-conditional-once-props) - [Prefetching](#once-props-prefetching) - [Combining with Other Prop Types](#once-props-combining-with-other-prop-types) +- [Live Props](#live-props) + - [Creating Live Props](#live-props-creating-live-props) + - [Client Setup](#live-props-client-setup) + - [Listening for Multiple Events](#live-props-listening-for-multiple-events) + - [Channels](#live-props-channels) + - [Throttling](#live-props-throttling) + - [Broadcasting Values](#live-props-broadcasting-values) + - [Tracking Refreshes](#live-props-tracking-refreshes) + - [Live Events](#live-props-live-events) + - [Skipping the Current Connection](#live-props-skipping-the-current-connection) + - [Custom Transports](#live-props-custom-transports) - [Polling](#polling) - [Poll Helper](#polling-poll-helper) - [Concurrency Mode](#polling-concurrency-mode) @@ -268,6 +279,7 @@ Version labels refer to the Inertia client-side libraries. Features marked with - [Location](#events-location) - [Prefetching](#events-prefetching) - [Prefetched](#events-prefetched) + - [Live](#events-live) - [Event Callbacks](#events-event-callbacks) - [Progress Indicators](#progress-indicators) - [Default](#progress-indicators-default) @@ -9588,6 +9600,464 @@ return Inertia::render('Dashboard', [ ]); ``` + + +## Live Props + +**v3.11.0+** + +Sometimes you may wish to keep a prop in sync with your server without any user interaction. Live props listen for your broadcast events and refresh the props those events affect, making them ideal for order statuses, unread counts, or dashboard metrics. + +Live props are refreshed using [partial reloads](#partial-reloads), so the client receives whatever your controller returns at that moment. Events may also [carry the new values](#live-props-broadcasting-values) themselves. + + + +### Creating Live Props + +To create a live prop, use the `Inertia::live()` method and pass the event that should refresh it. + +```php +use App\Events\OrderUpdated; + +return Inertia::render('Orders/Show', [ + 'order' => Inertia::live(fn () => $order, on: new OrderUpdated($order)), +]); +``` + +Inertia reads the channels from the event's `broadcastOn()` method, and the client reloads the `order` prop whenever the event is broadcast. + +Live props may be combined with [deferred](#deferred-props), [once](#once-props), [optional](#partial-reloads-lazy-data-evaluation), and always props. An optional live prop is only refreshed after the page has loaded it. [Merge props](#merging-props) may not be live. + +```php +return Inertia::render('Orders/Show', [ + 'metrics' => Inertia::defer(fn () => $order->metrics())->live(on: new OrderUpdated($order)), +]); +``` + + + +### Client Setup + +Live props receive events through a transport, which you pass to the `live` option of `createInertiaApp()`. Inertia includes transports for [Laravel Echo](/docs/{{version}}/broadcasting#client-side-installation) and `pusher-js`, and subscribes and unsubscribes automatically as the user navigates. + + + +#### Echo + +Configure Echo as usual, then pass the `echo()` transport. + +```js tab=Vue +import { createInertiaApp } from "@inertiajs/vue3"; +import { echo } from "@inertiajs/vue3/echo"; +import { configureEcho } from "@laravel/echo-vue"; + +configureEcho({ broadcaster: "reverb" }); + +createInertiaApp({ + live: echo(), + // ... +}); +``` + +```js tab=React +import { createInertiaApp } from "@inertiajs/react"; +import { echo } from "@inertiajs/react/echo"; +import { configureEcho } from "@laravel/echo-react"; + +configureEcho({ broadcaster: "reverb" }); + +createInertiaApp({ + live: echo(), + // ... +}); +``` + +```js tab=Svelte +import { createInertiaApp } from "@inertiajs/svelte"; +import { echo } from "@inertiajs/svelte/echo"; +import { configureEcho } from "@laravel/echo-svelte"; + +configureEcho({ broadcaster: "reverb" }); + +createInertiaApp({ + live: echo(), + // ... +}); +``` + +You may pass a `resolve` callback to `echo()` to use a different Echo instance, such as one created with `laravel-echo` directly. + +```js tab=Vue +import { createInertiaApp } from "@inertiajs/vue3"; +import { echo } from "@inertiajs/vue3/echo"; + +createInertiaApp({ + live: echo({ resolve: () => window.Echo }), + // ... +}); +``` + +```js tab=React +import { createInertiaApp } from "@inertiajs/react"; +import { echo } from "@inertiajs/react/echo"; + +createInertiaApp({ + live: echo({ resolve: () => window.Echo }), + // ... +}); +``` + +```js tab=Svelte +import { createInertiaApp } from "@inertiajs/svelte"; +import { echo } from "@inertiajs/svelte/echo"; + +createInertiaApp({ + live: echo({ resolve: () => window.Echo }), + // ... +}); +``` + + + +#### Pusher + +The `pusher()` transport accepts a `pusher-js` client, which works with any Pusher-compatible server, such as [Reverb](/docs/{{version}}/reverb). You are responsible for configuring the client, including channel authorization. + +```js +import { pusher } from "@inertiajs/core/pusher"; +import Pusher from "pusher-js"; + +const client = new Pusher("pusher-app-key", { + // ... +}); + +createInertiaApp({ + live: pusher({ client }), + // ... +}); +``` + + + +### Listening for Multiple Events + +You may pass an array of events to refresh a prop from more than one event. Each event uses its own channels. + +```php +return Inertia::render('Orders/Show', [ + 'order' => Inertia::live(fn () => $order, on: [ + new OrderUpdated($order), + new OrderShipped($order), + ]), +]); +``` + +Props listening for the same event are refreshed together in a single request. + + + +### Channels + +Live props listen on every channel your event [broadcasts on](/docs/{{version}}/broadcasting#defining-broadcast-events). An event broadcasting on both `orders.1` and `orders` therefore refreshes the prop whenever any order changes. You may pass the `channel` argument to listen on a specific channel instead. + +```php +return Inertia::render('Dashboard', [ + 'metrics' => Inertia::live(fn () => Metrics::today(), on: OrderUpdated::class, channel: new PrivateChannel('orders')), +]); +``` + +You may also pass an event class name instead of an instance, as long as its channels and `broadcastAs()` name don't depend on the event's data, since Inertia creates the event without calling its constructor. + +```php +class OrderUpdated implements ShouldBroadcast +{ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders'); + } +} + +Inertia::live(fn () => $order, on: OrderUpdated::class); +``` + +A bare string is a public channel, and each channel class defines its own type. + +```php +use Hypervel\Broadcasting\Channel; +use Hypervel\Broadcasting\EncryptedPrivateChannel; +use Hypervel\Broadcasting\PresenceChannel; +use Hypervel\Broadcasting\PrivateChannel; + +$channel = 'stats'; // public +$channel = new Channel('stats'); // public +$channel = new PrivateChannel('orders.1'); // private +$channel = new PresenceChannel('room.1'); // presence +$channel = new EncryptedPrivateChannel('orders.1'); // encrypted private + +Inertia::live($value, on: $event, channel: $channel); +``` + +Channels returned from `broadcastOn()` follow the same rules, so wrap non-public channel names in the appropriate channel class. + +```php +public function broadcastOn(): array +{ + return [ + 'stats', // public + new PrivateChannel('orders.1'), // private + ]; +} +``` + +Private, presence, and encrypted private channels are [authorized](/docs/{{version}}/broadcasting#authorizing-channels) through your `channels.php` routes as usual. If you [format outgoing channels](/docs/{{version}}/broadcasting#formatting-outgoing-channels), live props listen on the formatted channels your events are broadcast on. + + + +### Throttling + +By default, each live prop refreshes at most once per second. You may change this default using the `throttle` option, in milliseconds, of `echo()` or `pusher()`. + +```js tab=Vue +import { createInertiaApp } from "@inertiajs/vue3"; +import { echo } from "@inertiajs/vue3/echo"; + +createInertiaApp({ + live: echo({ throttle: 2000 }), + // ... +}); +``` + +```js tab=React +import { createInertiaApp } from "@inertiajs/react"; +import { echo } from "@inertiajs/react/echo"; + +createInertiaApp({ + live: echo({ throttle: 2000 }), + // ... +}); +``` + +```js tab=Svelte +import { createInertiaApp } from "@inertiajs/svelte"; +import { echo } from "@inertiajs/svelte/echo"; + +createInertiaApp({ + live: echo({ throttle: 2000 }), + // ... +}); +``` + +A prop may also define its own throttle. + +```php +return Inertia::render('Dashboard', [ + 'metrics' => Inertia::live(fn () => Metrics::today(), on: new MetricsUpdated, throttle: 5000), +]); +``` + +Each prop is throttled separately, so a busy prop never delays the rest of the page. Events received during the throttle window are collected into a single request once the window ends. Manual reloads using `router.reload()` are never throttled. + +A single action often broadcasts several events at once, so Inertia waits a few milliseconds before refreshing and collects every affected prop into one request. + +Refreshes pause while the browser tab is hidden and resume when the user returns. You may disable this using the `pauseWhenHidden` option. + + + +### Broadcasting Values + +By default, every listening page requests the new value from the server, so a dashboard open in a thousand tabs turns one event into a thousand requests. To avoid these requests, the event may broadcast the values itself using `Inertia::broadcastProps()`. + +```php +class OrderUpdated implements ShouldBroadcast +{ + public function __construct(public Order $order) {} + + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel("orders.{$this->order->id}"); + } + + public function broadcastWith(): array + { + return [ + 'order_id' => $this->order->id, + ...Inertia::broadcastProps([ + 'order' => OrderResource::make($this->order), + ]), + ]; + } +} +``` + +Inertia writes these values into the page without making a request. Keys are prop names, using dot notation for nested props. Values are resolved like page props, so resources, `Arrayable` objects, and closures all work. Every prop you pass is resolved, including deferred and optional props, since there is no page request to filter them. + +Props that don't listen for the event ignore its values, and props the event carries no value for are reloaded as usual. A prop that is already being reloaded also ignores the value, since that request may return newer data. + +> [!WARNING] +> Every subscriber of a channel receives the same payload, and channel authorization only runs when a user subscribes. Only broadcast values that every subscriber may see, and leave anything that depends on the user to a reload. Prefer resources over models, since a model broadcasts every attribute that isn't `$hidden`. + + + +### Tracking Refreshes + +The `useProp` hook returns a prop's value and whether it is being refreshed, making it ideal for subtle loading indicators. + +```vue tab=Vue + + + +``` + +```jsx tab=React +import { useProp } from "@inertiajs/react"; + +export default () => { + const order = useProp("order"); + + return
{order.value.status}
; +}; +``` + +```svelte tab=Svelte + + +
+ {order.value.status} +
+``` + +The hook also returns a `loaded` value, which is useful for live props that are also [deferred](#deferred-props). + + + +### Live Events + +Inertia fires a cancelable [`live` event](#events-live) before updating a live prop. Returning `false` skips the update, allowing you to show a "New data available" button instead. You may apply the update later using `router.reload({ only: event.detail.props })`. + +```js +router.on("live", (event) => { + console.log(event.detail.props); // the props about to be refreshed + console.log(event.detail.channel); // { name, type } + console.log(event.detail.event); // the broadcast event name + console.log(event.detail.payload); // the broadcast payload + + return false; +}); +``` + + + +### Skipping the Current Connection + +Inertia sends the socket ID as an `X-Socket-Id` header on same-origin requests, so the user who triggers a broadcast using `toOthers()` doesn't receive it. + +```php +broadcast(new OrderUpdated($order))->toOthers(); +``` + +You may provide your own socket ID using `socketId.resolveUsing()`, which takes precedence over the transport's. Passing `null` disables the header. + +```js tab=Vue +import { socketId } from "@inertiajs/vue3"; + +socketId.resolveUsing(() => mySocket.id); +socketId.resolveUsing(null); +``` + +```js tab=React +import { socketId } from "@inertiajs/react"; + +socketId.resolveUsing(() => mySocket.id); +socketId.resolveUsing(null); +``` + +```js tab=Svelte +import { socketId } from "@inertiajs/svelte"; + +socketId.resolveUsing(() => mySocket.id); +socketId.resolveUsing(null); +``` + + + +### Custom Transports + +You may pass any object implementing the `LiveTransport` interface to the `live` option, allowing you to receive events from your own WebSocket client, server-sent events, or a `BroadcastChannel`. + +```ts +import type { LiveTransport } from "@inertiajs/core"; + +const transport: LiveTransport = { + subscribe(channel, event, handler) { + // Called once per channel and event pair. Return a function that unsubscribes. + return () => {}; + }, + + // Optional. Lets Inertia send the socket ID so `toOthers()` works. + socketId: () => connection.id, + + // Optional. Inertia refreshes every live prop after a dropped connection + // returns, since events that fired while it was down never arrived. + onStatusChange(callback) { + return () => {}; + }, +}; + +createInertiaApp({ + live: transport, + // ... +}); +``` + +Channels arrive unprefixed with their type, such as `{ name: "orders.1", type: "private" }`, so `subscribe()` should apply any prefix your broadcaster expects. A broadcaster that subscribes per channel rather than per event should join the channel on its first listener and leave it after its last. Since `subscribe()` runs during a page swap, avoid throwing from it, as an error aborts the swap. + +The `onStatusChange()` callback receives a boolean indicating whether the connection is up. Inertia ignores repeated statuses and only acts when a dropped connection is restored. + +```ts +onStatusChange(callback) { + const handler = (state: string) => callback(state === "connected"); + + connection.on("state_change", handler); + + return () => connection.off("state_change", handler); +} +``` + +Review the [pusher.ts](https://github.com/inertiajs/inertia/blob/3.x/packages/core/src/pusher.ts) source for a reference implementation. It relies on a few internal helpers, such as prefixing channel names and counting the listeners on each channel, which your transport implements for its own broadcaster. + + + +#### Channel Transports + +Some broadcasters can't route messages by event name. Their transports may implement `subscribeToChannel()` instead, which is called once per channel. Inertia then reads the event name from each message's `__inertia.event` key. Review the [actionCable.ts](https://github.com/inertiajs/inertia/blob/3.x/packages/core/src/actionCable.ts) source for a reference implementation, which also uses internal helpers you'd write yourself. + +```ts +import type { LiveChannelTransport } from "@inertiajs/core"; + +const transport: LiveChannelTransport = { + subscribeToChannel(channel, handler) { + const stream = socket.subscribe(channel.name, handler); + + return () => stream.close(); + }, + + // Optional. Reads the event name from somewhere other than `__inertia.event`. + eventName: (payload) => (payload as { type?: string }).type, +}; +``` + ## Polling @@ -9596,7 +10066,7 @@ return Inertia::render('Dashboard', [ ### Poll Helper -Polling your server for new information on the current page is common, so Inertia provides a helper to reduce boilerplate. It automatically stops polling when the page is unmounted. +Polling your server for new information on the current page is common, so Inertia provides a helper to reduce boilerplate. It automatically stops polling when the page is unmounted. For data your server already broadcasts, [live props](#live-props) refresh a prop the moment its event fires instead of on an interval. The only required argument is the polling interval in milliseconds. @@ -9847,9 +10317,6 @@ usePoll(2000, {}, { }) ``` -> [!NOTE] -> The `keepAlive` option is deprecated in favor of `background: 'continue'`. - ## Prefetching @@ -12982,7 +13449,7 @@ removeStartEventListener(); ### Canceling Events -Some events, such as `before`, `networkError`, `httpException`, and `location`, support cancellation, allowing you to prevent Inertia's default behavior. Just like native events, the event will be canceled if only one event listener calls `event.preventDefault()`. +Some events, such as `before`, `networkError`, `httpException`, `location`, and `live`, support cancellation, allowing you to prevent Inertia's default behavior. Just like native events, the event will be canceled if only one event listener calls `event.preventDefault()`. ```js tab=Vue import { router } from "@inertiajs/vue3"; @@ -13600,6 +14067,40 @@ router.on("prefetched", (event) => { The `prefetched` event is not cancelable. + + +### Live + +**v3.11.0+** + +The `live` event fires whenever a broadcast event is about to refresh a [live prop](#live-props). The event detail includes the props that are about to be refreshed, the channel the broadcast arrived on, the broadcast event name, and its payload. + +```js tab=Vue +import { router } from "@inertiajs/vue3"; + +router.on("live", (event) => { + console.log(`Refreshing ${event.detail.props.join(", ")}`); +}); +``` + +```js tab=React +import { router } from "@inertiajs/react"; + +router.on("live", (event) => { + console.log(`Refreshing ${event.detail.props.join(", ")}`); +}); +``` + +```js tab=Svelte +import { router } from "@inertiajs/svelte"; + +router.on("live", (event) => { + console.log(`Refreshing ${event.detail.props.join(", ")}`); +}); +``` + +The `live` event is cancelable, allowing you to hold back the update and let the user decide when to see the new data. + ### Event Callbacks diff --git a/src/docs/mcp.md b/src/docs/mcp.md index de83db2ced..28357115ff 100644 --- a/src/docs/mcp.md +++ b/src/docs/mcp.md @@ -2106,7 +2106,7 @@ $client = Client::web('https://projects.example.com/mcp')->withHeaders([ ]); ``` -Custom headers stay on the configured URL's origin: its scheme, host and port. Redirects to another origin are followed without those headers, so connect to the destination server's URL directly if it needs them. +Custom headers are removed when a redirect changes the configured URL's scheme, host, or port. They remain removed for the rest of that redirect chain, even if it returns to the original origin. A new request to the configured URL sends them again. Connect to the destination server's URL directly if it needs those headers. #### Linking User Accounts With OAuth diff --git a/src/docs/opentelemetry.md b/src/docs/opentelemetry.md index 596b35b2ba..17de22fa92 100644 --- a/src/docs/opentelemetry.md +++ b/src/docs/opentelemetry.md @@ -186,7 +186,7 @@ The shipped defaults are: | HTTP server | Request spans | `http.server.request.duration`; `http.server.active_requests` is off | | HTTP client | Physical request spans | `http.client.request.duration` | | Database and Redis | Client operation spans | `db.client.operation.duration` | -| Database and Redis pools | None | `db.client.connection.count`, `db.client.connection.max`, `db.client.connection.pending_requests` | +| Database and Redis pools | None | `db.client.connection.count`, `db.client.connection.limit`, `db.client.connection.pending_requests` | | Cache | Events on the current span | `hypervel.cache.operations` | | Queue | Producer and consumer spans | `messaging.client.sent.messages`, `messaging.client.operation.duration`, `messaging.client.consumed.messages`, `messaging.process.duration`; `hypervel.queue.jobs` is off | | Queue outbox | No additional spans | `hypervel.queue.outbox.delivered.messages`, `hypervel.queue.outbox.delivery.duration`, `hypervel.queue.outbox.delivery.latency` | @@ -198,7 +198,7 @@ The shipped defaults are: | gRPC | Logical client and server RPC spans | `rpc.client.call.duration`, `rpc.server.call.duration` | | WebSockets | Message spans | `hypervel.websocket.message.duration`, `hypervel.websocket.messages`, `hypervel.websocket.active_connections` | | Exceptions | Error log records | `hypervel.exceptions` | -| Object pools | None | `hypervel.object_pool.objects`, `hypervel.object_pool.max`, `hypervel.object_pool.pending_requests` | +| Object pools | None | `hypervel.object_pool.objects`, `hypervel.object_pool.limit`, `hypervel.object_pool.pending_requests` | | PHP runtime | None | memory, GC, process CPU/context-switch, and OPcache metrics | | Swoole server and workers | None | connections, requests, task activity/queue depth, worker requests, and coroutine count | @@ -653,7 +653,7 @@ class QueueDurationView implements MetricView public function template(): ViewTemplate { return ViewTemplate::create()->withAttributeKeys([ - 'messaging.system', + 'messaging.system.name', 'messaging.destination.name', 'messaging.operation.name', 'error.type', @@ -800,4 +800,4 @@ Observable runtime, server, pool, and queue-depth metrics collect from existing The package keeps bounded SDK queues and coroutine-scoped operation state. It does not store telemetry on disk or in an application database. Monitor queue loss, worker RSS, backend request volume, and dynamic metric dimensions under production-like concurrency when changing queue sizes, intervals, internal metrics, compression, or custom extensions. -The database-pool and messaging metric conventions used by the current OpenTelemetry semantic-conventions package are marked development, while the RPC conventions are release candidate. Their names or attributes may change in a future framework release as the standard stabilizes. +Built-in instrumentation follows OpenTelemetry semantic conventions 1.45 and reports that schema URL on its instrumentation scopes. Some of these conventions, including the database-pool and messaging conventions, are still marked development, so their names or attributes may change in a future framework release as the standard stabilizes. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 369163d5e8..fbb2ae7ee5 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -720,7 +720,7 @@ Laravel's deprecated `InvokableRule` contract is not available. Change rules tha When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` and review the [Data Objects documentation](/docs/{{version}}/data-objects). The familiar `Data`, `Dto`, `Resource`, `Optional`, mapping, casting, validation, lazy-value, collection, resource, and Eloquent APIs are all available. -Replace Spatie's `From*` attributes with Hypervel contextual constructor attributes. `UnserializeCast` is not included; write a custom cast that passes `allowed_classes` to `unserialize()` for trusted values. Livewire and TypeScript integrations are also not included. +Replace Spatie's `From*` attributes with Hypervel contextual constructor attributes. Livewire and TypeScript integrations are not included. In `config/data.php`, the `casts`, `transformers`, `normalizers`, and `rule_inferrers` options only hold your own extensions; remove Spatie's built-in entries, since Hypervel's built-in handling is fixed. If you enabled Spatie's optional `FormRequestNormalizer`, keep it as `Hypervel\Data\Normalizers\FormRequestNormalizer`. Typed iterable items are always cast and transformed, and an array given to a collection property becomes that collection. @@ -734,6 +734,7 @@ Review these behavior differences in ported code: - Custom `pipeline()` overrides and `DataPipe` classes are not supported. Rebuild them with named factories, `prepareForPipeline()`, or factory hooks. - A value that a union property already accepts is kept, such as a string for `string|SongData` or an array for `array|Collection`, and validation applies the rules of the declared type that holds it. Declare `Collection` alone when the property should always hold a collection. See [type conversion](/docs/{{version}}/data-objects#type-conversion). - A model attribute holding `null` is passed as `null` instead of falling back to the property's default or `Optional`. Columns that were not selected still count as missing. +- `UnserializeCast` restores no objects unless their classes are listed in `allowedClasses`. List the classes your stored values contain, including nested ones. See [unserializing values](/docs/{{version}}/data-objects#unserializing-values). - A custom cast's `$properties` contains only declared property values keyed by PHP property name, without undeclared input or raw input names. - `CreationContext::$dataClass` is the class the creation started with, even while nested objects are created, and the context has no `from()`, `collect()`, or `currentPath`. Replace `$context->from()` with `TargetData::factory($context)->from()`, which copies the context's options but not its hooks. A cast can read `$property->className` for the class that declares the property. - An array or collection cannot be collected into a paginator target or given to a paginator property. Pass a Hypervel paginator so its pagination details are kept. diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 0241c094f7..470db843e8 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -679,6 +679,8 @@ $request->authenticate(new MultiAuthenticator( )); ``` +Headers set by an authenticator are removed when a redirect changes the scheme, host, or port. They remain removed for the rest of that redirect chain, even if it returns to the original origin. A new retry attempt applies authentication again. Custom headers added through `defaultHeaders` or `withHeaders` outside an authenticator are ordinary headers and may be forwarded to another origin. + Built-in NTLM authentication is not provided. Integrations requiring NTLM must supply their own authenticator and transport middleware. You may create a reusable authenticator by implementing `Hypervel\Saloon\Contracts\Authenticator`: diff --git a/src/inertia/composer.json b/src/inertia/composer.json index e8629add34..0fa3755d0f 100644 --- a/src/inertia/composer.json +++ b/src/inertia/composer.json @@ -36,11 +36,13 @@ "symfony/console": "^8.1.10", "symfony/http-foundation": "^8.1", "symfony/process": "^8.1", + "hypervel/broadcasting": "^0.4", "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/guzzle-promises": "^3.1", diff --git a/src/inertia/src/AlwaysProp.php b/src/inertia/src/AlwaysProp.php index 68b838f16c..21fc79c35a 100644 --- a/src/inertia/src/AlwaysProp.php +++ b/src/inertia/src/AlwaysProp.php @@ -4,8 +4,9 @@ namespace Hypervel\Inertia; -class AlwaysProp +class AlwaysProp implements SupportsLiveUpdates { + use HasLiveUpdates; use ResolvesCallables; /** diff --git a/src/inertia/src/DeferProp.php b/src/inertia/src/DeferProp.php index b0972dc580..0a7d53d3c7 100644 --- a/src/inertia/src/DeferProp.php +++ b/src/inertia/src/DeferProp.php @@ -4,9 +4,10 @@ namespace Hypervel\Inertia; -class DeferProp implements Deferrable, IgnoreFirstLoad, Mergeable, Onceable, Rescuable +class DeferProp implements Deferrable, IgnoreFirstLoad, Mergeable, Onceable, Rescuable, SupportsLiveUpdates { use DefersProps; + use HasLiveUpdates; use MergesProps; use ResolvesCallables; use ResolvesOnce; diff --git a/src/inertia/src/DevTools/Collector.php b/src/inertia/src/DevTools/Collector.php index e58ca46817..f2af90840a 100644 --- a/src/inertia/src/DevTools/Collector.php +++ b/src/inertia/src/DevTools/Collector.php @@ -96,6 +96,7 @@ public function addProp( ?string $deferGroup = null, bool $reset = false, bool $once = false, + bool $live = false, ?string $mergeDirection = null, bool $deepMerge = false, bool $rescued = false, @@ -121,6 +122,10 @@ public function addProp( $this->props[$path]['once'] = true; } + if ($live) { + $this->props[$path]['live'] = true; + } + if ($mergeDirection !== null) { $this->props[$path]['mergeDirection'] = $mergeDirection; } diff --git a/src/inertia/src/DevTools/EntriesRepository.php b/src/inertia/src/DevTools/EntriesRepository.php index 395446d6ae..472df95e1f 100644 --- a/src/inertia/src/DevTools/EntriesRepository.php +++ b/src/inertia/src/DevTools/EntriesRepository.php @@ -4,6 +4,7 @@ namespace Hypervel\Inertia\DevTools; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Str; use InvalidArgumentException; @@ -409,7 +410,11 @@ protected function readLockedTextFile(string $file): ?string try { // Filesystem::get with a lock takes a shared (LOCK_SH) read lock. return $this->files->get($file, lock: true); - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } } diff --git a/src/inertia/src/DevTools/EntryStore.php b/src/inertia/src/DevTools/EntryStore.php index 0653c09a6e..394df9a963 100644 --- a/src/inertia/src/DevTools/EntryStore.php +++ b/src/inertia/src/DevTools/EntryStore.php @@ -4,6 +4,7 @@ namespace Hypervel\Inertia\DevTools; +use Hypervel\Coroutine\Coroutine; use Hypervel\Inertia\DevTools\Data\IncomingEntry; use Hypervel\Support\Facades\Log; use Throwable; @@ -78,6 +79,12 @@ public function flush(EntriesRepository $repo): void static::$suppressedUntil = null; } catch (Throwable $e) { + // A canceled request is not a storage failure, so it must not suspend recording + // for the rest of the worker. + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + $firstFailure = static::$suppressedUntil === null; static::$suppressedUntil = microtime(true) + self::SUPPRESS_SECONDS; @@ -85,9 +92,12 @@ public function flush(EntriesRepository $repo): void if ($firstFailure) { try { Log::warning('Inertia DevTools: failed to persist entry: ' . $e->getMessage()); - } catch (Throwable) { + } catch (Throwable $exception) { // The log often shares the failing storage, such as a full disk, and // recording must still never break the response. + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } } } } diff --git a/src/inertia/src/DevTools/PropClassifier.php b/src/inertia/src/DevTools/PropClassifier.php index 529273c2fd..10ff08e924 100644 --- a/src/inertia/src/DevTools/PropClassifier.php +++ b/src/inertia/src/DevTools/PropClassifier.php @@ -16,6 +16,7 @@ use Hypervel\Inertia\OptionalProp; use Hypervel\Inertia\ScrollProp; use Hypervel\Inertia\Support\Header; +use Hypervel\Inertia\SupportsLiveUpdates; /** * Maps Inertia prop wrapper instances to the stable metadata the extension renders. @@ -27,7 +28,7 @@ class PropClassifier /** * Classify the given resolved prop. * - * @return array{inertiaType: ?PropType, deferGroup: ?string, reset: bool, once: bool, mergeDirection: ?string, deepMerge: bool} + * @return array{inertiaType: ?PropType, deferGroup: ?string, reset: bool, once: bool, live: bool, mergeDirection: ?string, deepMerge: bool} */ public function classifyResolved(string $path, mixed $prop, Request $request): array { @@ -41,6 +42,7 @@ public function classifyResolved(string $path, mixed $prop, Request $request): a 'deferGroup' => $this->deferGroup($prop, $isDeferredDelivery), 'reset' => in_array($path, $this->parseDevToolsHeader($request, Header::RESET), true), 'once' => $prop instanceof Onceable && $prop->shouldResolveOnce(), + 'live' => $prop instanceof SupportsLiveUpdates && $prop->isLive(), 'mergeDirection' => $this->mergeDirection($prop), 'deepMerge' => $this->isDeepMerge($prop), ]; @@ -72,15 +74,17 @@ protected function deferGroup(mixed $prop, bool $isDeferredDelivery): ?string } /** - * A prop is a deep merge when it deep-merges nested data (`->deepMerge()`) or matches - * array items on a key (`->matchOn()`) to upsert them rather than blindly appending. - * Matching has no effect unless the prop merges. + * A prop is a deep merge when it merges nested data or upserts matched + * array items. `->matchOn()` on its own leaves merging off, matching the + * page object behavior. */ protected function isDeepMerge(mixed $prop): bool { - return $prop instanceof Mergeable - && $prop->shouldMerge() - && ($prop->shouldDeepMerge() || count($prop->matchesOn()) > 0); + if (! $prop instanceof Mergeable || ! $prop->shouldMerge()) { + return false; + } + + return $prop->shouldDeepMerge() || count($prop->matchesOn()) > 0; } /** diff --git a/src/inertia/src/DevTools/RequestRecorder.php b/src/inertia/src/DevTools/RequestRecorder.php index 5c699674a6..c0ec9cb514 100644 --- a/src/inertia/src/DevTools/RequestRecorder.php +++ b/src/inertia/src/DevTools/RequestRecorder.php @@ -160,6 +160,7 @@ protected function recordProp(string $path, mixed $prop, bool $rescued = false): $meta['deferGroup'], $meta['reset'], $meta['once'], + $meta['live'], $meta['mergeDirection'], $meta['deepMerge'], rescued: $rescued, diff --git a/src/inertia/src/HasLiveUpdates.php b/src/inertia/src/HasLiveUpdates.php new file mode 100644 index 0000000000..726ee54c2d --- /dev/null +++ b/src/inertia/src/HasLiveUpdates.php @@ -0,0 +1,367 @@ + '', + 'private' => 'private-', + 'presence' => 'presence-', + 'encrypted-private' => 'private-encrypted-', + ]; + + /** + * Indicates if the property should receive live updates. + */ + protected bool $live = false; + + /** + * The events and channels each [live] call declared, kept per call so an + * explicit channel only applies to its own events. + * + * @var array, channels: array}> + */ + protected array $liveDeclarations = []; + + /** + * The number of milliseconds to throttle live updates by. + */ + protected ?int $liveThrottle = null; + + /** + * Mark the property as live, refreshing it whenever one of the given events + * is received. Merge properties cannot be live. + * + * @param null|array|class-string|object|string $on + * @param null|array|Channel|string $channel + */ + public function live(array|object|string|null $on = null, array|Channel|string|null $channel = null, ?int $throttle = null): static + { + if ($this instanceof Mergeable && $this->shouldMerge()) { + throw new LogicException('Live updates are not supported on merge properties.'); + } + + $this->live = true; + $this->liveDeclarations[] = ['events' => Arr::wrap($on), 'channels' => Arr::wrap($channel)]; + + if ($throttle !== null) { + $this->liveThrottle = $throttle; + } + + return $this; + } + + /** + * Determine if the property should receive live updates. + */ + public function isLive(): bool + { + return $this->live; + } + + /** + * Get the number of milliseconds to throttle live updates by. + */ + public function liveThrottle(): ?int + { + return $this->liveThrottle; + } + + /** + * Get the live-update listeners that should refresh the property. A channel + * passed to [live] applies only to the events it was passed with. + * + * @return array}> + */ + public function liveListeners(): array + { + $hasEvents = collect($this->liveDeclarations)->contains(fn ($declaration) => count($declaration['events']) > 0); + + if (! $hasEvents) { + throw new LogicException('A live property must listen for at least one event. Pass the event to the [on] argument.'); + } + + $listeners = []; + + foreach ($this->liveDeclarations as $declaration) { + $hasChannels = count($declaration['channels']) > 0; + + foreach ($declaration['events'] as $event) { + $eventName = $this->resolveEventName($event); + + $channels = $hasChannels ? $declaration['channels'] : $this->channelsFromEvent($event); + + foreach ($this->resolveChannels($channels) as $channel) { + $this->addLiveListener($listeners, $channel, [$eventName]); + } + } + } + + return array_values($listeners); + } + + /** + * Resolve the name Hypervel broadcasts the given event as. + * + * @param class-string|object|string $event + */ + protected function resolveEventName(object|string $event): string + { + // Broadcast names are cast like the broadcast job casts them, so an integer-backed enum works. + if (is_object($event)) { + return method_exists($event, 'broadcastAs') + ? (string) enum_value($event->broadcastAs()) + : $event::class; + } + + // A missing class would leave a listener that never fires. Names without + // a namespace separator are literal, and may come from non-Hypervel producers. + if (str_contains($event, '\\') && ! class_exists($event)) { + throw new LogicException("The [{$event}] event class does not exist. Pass an existing event class to the [on] argument, or its broadcast name as a string."); + } + + // A broadcast name is read the same way the channels are, off an event + // built without its payload, which is all a fixed name needs. + if (class_exists($event) && (new ReflectionClass($event))->hasMethod('broadcastAs')) { + try { + return (string) enum_value((new ReflectionClass($event))->newInstanceWithoutConstructor()->broadcastAs()); + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + throw new LogicException("Unable to resolve the broadcast name for the [{$event}] event because it builds it from its payload. Pass an event instance to the [on] argument instead of its class name.", previous: $exception); + } + } + + return $event; + } + + /** + * Gather the channels the given event broadcasts on. A class name is built + * without its payload, which is all an event needs when its channels do not + * depend on one. + * + * @param class-string|object|string $event + * @return array + */ + protected function channelsFromEvent(object|string $event): array + { + if (! is_string($event)) { + return $this->broadcastChannels($event); + } + + if (! class_exists($event)) { + throw new LogicException("Unable to resolve the channels for the [{$event}] event because it was given as a string. Pass the [channel] argument, or pass an event instance to the [on] argument."); + } + + try { + return $this->broadcastChannels((new ReflectionClass($event))->newInstanceWithoutConstructor()); + } catch (LogicException $exception) { + throw $exception; + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + + throw new LogicException("Unable to resolve the channels for the [{$event}] event because it builds them from its payload. Pass an event instance to the [on] argument, or pass the [channel] argument.", previous: $exception); + } + } + + /** + * Read the channels off an event instance. + * + * @return array + */ + protected function broadcastChannels(object $event): array + { + if (! method_exists($event, 'broadcastOn')) { + throw new LogicException('The [' . $event::class . '] event does not define a [broadcastOn] method. Pass the [channel] argument instead.'); + } + + $channels = Arr::wrap($event->broadcastOn()); + + // Channels keyed by connection are read for the default one, the way + // Hypervel's broadcast job picks them per connection + if (collect($channels)->contains(fn ($channel) => is_array($channel))) { + $channels = $this->channelsForDefaultConnection($event, $channels); + } + + if (count($channels) === 0) { + throw new LogicException('The [' . $event::class . '] event does not broadcast on any channels. Pass the [channel] argument instead.'); + } + + return $channels; + } + + /** + * Get the channels the given event broadcasts on for the default connection. + * + * @param array $channels + * @return array + */ + protected function channelsForDefaultConnection(object $event, array $channels): array + { + $connection = config()->string('broadcasting.default'); + + $connections = method_exists($event, 'broadcastConnections') ? $event->broadcastConnections() : [null]; + + // The broadcast job sends each connection the channels keyed by its name, or by '' + // when the event names no connection, which sends it on the default connection. + $defaultChannels = []; + + foreach ($connections as $name) { + if (($name === null || $name === $connection) && is_array($channels[$name ?? ''] ?? null)) { + array_push($defaultChannels, ...array_values($channels[$name ?? ''])); + } + } + + if ($defaultChannels === []) { + throw new LogicException('The [' . $event::class . "] event keys its channels by connection, but has none for the default [{$connection}] connection. Pass the [channel] argument instead."); + } + + return $defaultChannels; + } + + /** + * Add the given events to the listener for the given channel. + * + * @param array}> $listeners + * @param array{name: string, type: string} $channel + * @param array $events + */ + protected function addLiveListener(array &$listeners, array $channel, array $events): void + { + $key = "{$channel['type']}:{$channel['name']}"; + + $listeners[$key] ??= [ + 'channel' => $channel, + 'events' => [], + ]; + + foreach ($events as $event) { + if (! in_array($event, $listeners[$key]['events'], true)) { + $listeners[$key]['events'][] = $event; + } + } + } + + /** + * Resolve the given channels into the unprefixed names and types the broadcaster sends them on. + * + * @param array $channels + * @return array + */ + protected function resolveChannels(array $channels): array + { + $formatter = Broadcaster::channelFormatter(); + + if ($formatter === null) { + return array_map($this->resolveChannel(...), $channels); + } + + // Hypervel's outgoing channel formatter renames every channel before the broadcaster + // sends it, so clients must subscribe to the formatted names. Formatted channels are + // strings, so the type is read back from the prefix the broadcaster sends them with. + // A HasBroadcastChannel value is formatted under its channel name, as resolveChannel() reads it. + $channels = array_map( + fn (Channel|HasBroadcastChannel|string $channel): Channel|string => $channel instanceof HasBroadcastChannel + ? $channel->broadcastChannel() + : $channel, + $channels, + ); + + $resolved = []; + + foreach ($formatter($channels) as $channel) { + $channel = (string) $channel; + + $type = match (true) { + str_starts_with($channel, self::CHANNEL_PREFIXES['encrypted-private']) => 'encrypted-private', + str_starts_with($channel, self::CHANNEL_PREFIXES['private']) => 'private', + str_starts_with($channel, self::CHANNEL_PREFIXES['presence']) => 'presence', + default => 'public', + }; + + $resolved[] = [ + 'name' => substr($channel, strlen(self::CHANNEL_PREFIXES[$type])), + 'type' => $type, + ]; + } + + return $resolved; + } + + /** + * Resolve the given channel into its unprefixed name and type. Hypervel's + * prefixes follow Pusher's convention, so transports get the type separately + * and apply their own naming. + * + * @return array{name: string, type: string} + */ + protected function resolveChannel(Channel|HasBroadcastChannel|string $channel): array + { + if ($channel instanceof HasBroadcastChannel) { + $channel = $channel->broadcastChannel(); + } + + if (is_string($channel)) { + // Hypervel's broadcaster casts a channel to a string without adding a + // prefix, so a bare string is a public channel wherever it is given. + // Use `new PrivateChannel(...)` to subscribe privately. + return ['name' => $channel, 'type' => 'public']; + } + + $type = $this->channelType($channel); + + return [ + 'name' => $this->channelName($channel, $type), + 'type' => $type, + ]; + } + + /** + * Resolve the type of the given channel. + */ + protected function channelType(Channel $channel): string + { + return match (true) { + $channel instanceof EncryptedPrivateChannel => 'encrypted-private', + $channel instanceof PresenceChannel => 'presence', + $channel instanceof PrivateChannel => 'private', + default => 'public', + }; + } + + /** + * Resolve the name of the given channel, without the prefix Hypervel's channel class added to it. + */ + protected function channelName(Channel $channel, string $type): string + { + $prefix = self::CHANNEL_PREFIXES[$type]; + + return $prefix !== '' && str_starts_with($channel->name, $prefix) + ? substr($channel->name, strlen($prefix)) + : $channel->name; + } +} diff --git a/src/inertia/src/Inertia.php b/src/inertia/src/Inertia.php index fd2f50b2fa..26f02072ec 100644 --- a/src/inertia/src/Inertia.php +++ b/src/inertia/src/Inertia.php @@ -9,6 +9,7 @@ /** * @method static \Hypervel\Inertia\AlwaysProp always(mixed $value) * @method static \Hypervel\Http\RedirectResponse back(int $status = 302, array $headers = [], string|bool $fallback = false) + * @method static array broadcastProps(array $props) * @method static void clearHistory() * @method static void configureSsrRequestUsing(\Closure|null $callback = null) * @method static \Hypervel\Inertia\MergeProp deepMerge(mixed $value) @@ -24,6 +25,7 @@ * @method static string getVersion() * @method static void handleExceptionsUsing(callable $callback) * @method static bool hasMacro(string $name) + * @method static \Hypervel\Inertia\LiveProp live(mixed $value, null|array|string|object $on = null, null|array|\Hypervel\Broadcasting\Channel|string $channel = null, int|null $throttle = null) * @method static \Symfony\Component\HttpFoundation\Response location(\Symfony\Component\HttpFoundation\RedirectResponse|string $url) * @method static void macro(string $name, callable|object $macro) * @method static \Hypervel\Inertia\MergeProp merge(mixed $value) diff --git a/src/inertia/src/LiveProp.php b/src/inertia/src/LiveProp.php new file mode 100644 index 0000000000..90ec48969a --- /dev/null +++ b/src/inertia/src/LiveProp.php @@ -0,0 +1,39 @@ +|class-string|object|string $on + * @param null|array|Channel|string $channel + */ + public function __construct(mixed $value, array|object|string|null $on = null, array|Channel|string|null $channel = null, ?int $throttle = null) + { + $this->value = $value; + $this->live($on, $channel, $throttle); + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->value); + } +} diff --git a/src/inertia/src/MergesProps.php b/src/inertia/src/MergesProps.php index 1595ac35ab..3c5e33cd74 100644 --- a/src/inertia/src/MergesProps.php +++ b/src/inertia/src/MergesProps.php @@ -5,6 +5,7 @@ namespace Hypervel\Inertia; use Hypervel\Support\Arr; +use LogicException; trait MergesProps { @@ -45,10 +46,15 @@ trait MergesProps protected array $prependsAtPaths = []; /** - * Mark the property for merging. + * Mark the property for merging. A live property cannot be merged, since + * refreshing it would append rather than replace its value. */ public function merge(): static { + if ($this instanceof SupportsLiveUpdates && $this->isLive()) { + throw new LogicException('Live updates are not supported on merge properties.'); + } + $this->merge = true; return $this; diff --git a/src/inertia/src/OnceProp.php b/src/inertia/src/OnceProp.php index 0c9b2bdee1..c6b307d28d 100644 --- a/src/inertia/src/OnceProp.php +++ b/src/inertia/src/OnceProp.php @@ -4,8 +4,9 @@ namespace Hypervel\Inertia; -class OnceProp implements Onceable +class OnceProp implements Onceable, SupportsLiveUpdates { + use HasLiveUpdates; use ResolvesCallables; use ResolvesOnce; diff --git a/src/inertia/src/OptionalProp.php b/src/inertia/src/OptionalProp.php index 3b2bba76c1..8af7ea747f 100644 --- a/src/inertia/src/OptionalProp.php +++ b/src/inertia/src/OptionalProp.php @@ -4,8 +4,9 @@ namespace Hypervel\Inertia; -class OptionalProp implements IgnoreFirstLoad, Onceable +class OptionalProp implements IgnoreFirstLoad, Onceable, SupportsLiveUpdates { + use HasLiveUpdates; use ResolvesCallables; use ResolvesOnce; diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php index 3d5a742e45..8cdca8ef77 100644 --- a/src/inertia/src/PropsResolver.php +++ b/src/inertia/src/PropsResolver.php @@ -8,6 +8,7 @@ use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Responsable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Inertia\DevTools\DevTools; use Hypervel\Inertia\DevTools\RequestRecorder; @@ -114,17 +115,24 @@ class PropsResolver /** * The scroll pagination metadata for each scroll prop. * - * @var array> + * @var array */ protected array $scrollProps = []; /** * The once-prop metadata for each once prop. * - * @var array> + * @var array */ protected array $onceProps = []; + /** + * The live update listeners for each live prop. + * + * @var array}>, throttle?: int}> + */ + protected array $liveProps = []; + /** * The top-level keys of shared props. * @@ -174,6 +182,45 @@ public function resolve(array $shared, array $props): array ]; } + /** + * Resolve the given broadcast props. Dot-notation keys stay flat and literal, + * since only a page response unpacks them into nested arrays. + * + * @param array $props + * @return array + */ + public function resolveBroadcastProps(array $props, string $prefix = ''): array + { + $props = $this->resolvePropertyProviders($props); + $result = []; + + foreach ($props as $key => $value) { + $path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; + + // Resolution mutates a scroll prop, so a reused one resolves through its own copy, + // as it does for a page response. + $prop = $value instanceof ScrollProp ? clone $value : $value; + + $value = $this->resolveValue($prop, $path, $props); + + if ($value !== $prop && $this->isPropType($value)) { + $value = $this->resolveValue($value instanceof ScrollProp ? clone $value : $value, $path, $props); + } + + // A rescued prop resolved to null, so leave it out rather than + // broadcasting null over data the client already has. + if (in_array($path, $this->rescuedProps, true)) { + continue; + } + + $result[$key] = is_array($value) + ? $this->resolveBroadcastProps($value, $path) + : $value; + } + + return $result; + } + /** * Resolve shared property providers and collect shared prop keys. * @@ -244,6 +291,7 @@ protected function buildMetadata(): array 'rescuedProps' => $this->rescuedProps, 'scrollProps' => $this->scrollProps, 'onceProps' => $this->onceProps, + 'liveProps' => $this->liveProps, ], fn ($value) => count($value) > 0); } @@ -281,12 +329,6 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $value = $this->resolveValue($prop, $path, $props); - if (in_array($path, $this->rescuedProps, true)) { - $this->recorder?->propRescued($path, $prop); - - continue; - } - // A closure may return a prop type instead of a plain value. When // this happens, we unwrap it one more level so the prop type can // participate in filtering and metadata collection below. @@ -302,7 +344,15 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $value = $this->resolveValue($prop, $path, $props); } - $this->collectMetadata($prop, $path); + // Checked after unwrapping, so a rescued prop returned from a closure + // is left out with its metadata too, instead of resolving to null. + if (in_array($path, $this->rescuedProps, true)) { + $this->recorder?->propRescued($path, $prop); + + continue; + } + + $this->collectMetadata($prop, $path, $parentWasResolved); $this->recorder?->propResolved($path, $prop); // When the resolved value is an array, we recurse into it. If the @@ -322,7 +372,7 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW */ protected function shouldIncludeInPartialResponse(mixed $prop, string $path, bool $parentWasResolved): bool { - if (! $this->isPartial || $prop instanceof AlwaysProp || $parentWasResolved) { + if (! $this->isPartial || $this->bypassesPartialFiltering($prop, $parentWasResolved)) { return true; } @@ -333,6 +383,14 @@ protected function shouldIncludeInPartialResponse(mixed $prop, string $path, boo return is_array($prop) && $this->containsAlwaysProp($prop); } + /** + * Determine if a prop is resolved independently of partial request filters. + */ + protected function bypassesPartialFiltering(mixed $prop, bool $parentWasResolved): bool + { + return $prop instanceof AlwaysProp || $parentWasResolved; + } + /** * Determine if an already-materialized prop tree contains an always prop. * @@ -373,6 +431,11 @@ protected function pathMatchesPartialRequest(string $path): bool */ protected function excludeFromInitialResponse(mixed $prop, string $path): bool { + // Excluding a prop from the initial response never removes its live listeners. + if ($prop instanceof SupportsLiveUpdates && $prop->isLive()) { + $this->collectLiveMetadata($path, $prop, false); + } + // OptionalProp and DeferProp implement IgnoreFirstLoad and are never // sent on the initial page load. They still contribute deferred, // merge, and once metadata for the client to act on. @@ -498,7 +561,8 @@ protected function resolveValue(mixed $value, string $path, array $siblings): mi return $value; } catch (Throwable $e) { - if (! $shouldRescue) { + // Rescuing covers failures of the prop itself, never the canceled request around it. + if (! $shouldRescue || Coroutine::causedByCancellation($e)) { throw $e; } @@ -519,6 +583,7 @@ protected function isPropType(mixed $value): bool return $value instanceof AlwaysProp || $value instanceof Deferrable || $value instanceof IgnoreFirstLoad + || $value instanceof SupportsLiveUpdates || $value instanceof Mergeable || $value instanceof Onceable; } @@ -526,7 +591,7 @@ protected function isPropType(mixed $value): bool /** * Collect metadata for a prop that will be included in the response. */ - protected function collectMetadata(mixed $prop, string $path): void + protected function collectMetadata(mixed $prop, string $path, bool $parentWasResolved): void { if ($prop instanceof Mergeable && $prop->shouldMerge()) { $this->collectMergeableMetadata($path, $prop); @@ -539,6 +604,13 @@ protected function collectMetadata(mixed $prop, string $path): void if ($prop instanceof Onceable && $prop->shouldResolveOnce()) { $this->collectOnceMetadata($path, $prop); } + + // On an initial load, excludeFromInitialResponse() has already collected the + // listeners of every prop that reaches here. Collecting them again would call + // each event's broadcastOn() a second time for the same result. + if ($this->isPartial && $prop instanceof SupportsLiveUpdates && $prop->isLive()) { + $this->collectLiveMetadata($path, $prop, $parentWasResolved); + } } /** @@ -614,6 +686,25 @@ protected function collectOnceMetadata(string $path, mixed $prop): void ]; } + /** + * Collect the live update listeners for a live prop. Emitted on every + * response, so a partial reload keeps the listeners it refreshed up to date. + */ + protected function collectLiveMetadata(string $path, SupportsLiveUpdates $prop, bool $parentWasResolved): void + { + // A partial reload only reaches here for a live prop it sends, and sends it whole, + // even when the request names one of its descendants or the prop resolves outside + // the filters. Its listeners go with it, since model-bound channels may change. + // $parentWasResolved keeps upstream's signature for resolvers that override this. + $this->liveProps[$path] = [ + 'listeners' => $prop->liveListeners(), + ]; + + if ($prop->liveThrottle() !== null) { + $this->liveProps[$path]['throttle'] = $prop->liveThrottle(); + } + } + /** * Determine if the path should contribute metadata during a partial request. */ @@ -636,7 +727,7 @@ protected function isIncludedInPartialMetadata(string $path): bool protected function matchesOnly(string $path): bool { foreach ($this->only as $onlyPath) { - if ($path === $onlyPath || str_starts_with($path, "{$onlyPath}.")) { + if ($this->pathIsSameOrDescendant($path, $onlyPath)) { return true; } } @@ -664,7 +755,7 @@ protected function leadsToOnly(string $path): bool protected function matchesExcept(string $path): bool { foreach ($this->except as $exceptPath) { - if ($path === $exceptPath || str_starts_with($path, "{$exceptPath}.")) { + if ($this->pathIsSameOrDescendant($path, $exceptPath)) { return true; } } @@ -672,6 +763,14 @@ protected function matchesExcept(string $path): bool return false; } + /** + * Determine if a dot path matches another path or belongs to its subtree. + */ + protected function pathIsSameOrDescendant(string $path, string $parent): bool + { + return $path === $parent || str_starts_with($path, "{$parent}."); + } + /** * Unpack top-level dot-notation keys into nested arrays. * diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index c1ba795d67..6780213efe 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -6,6 +6,7 @@ use BackedEnum; use Closure; +use Hypervel\Broadcasting\Channel; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Http\Kernel; use Hypervel\Contracts\Support\Arrayable; @@ -266,6 +267,17 @@ public function scroll(mixed $value, string $wrapper = 'data', ProvidesScrollMet return new ScrollProp($value, $wrapper, $metadata); } + /** + * Create a live property. + * + * @param null|array|class-string|object|string $on + * @param null|array|Channel|string $channel + */ + public function live(mixed $value, array|object|string|null $on = null, array|Channel|string|null $channel = null, ?int $throttle = null): LiveProp + { + return new LiveProp($value, $on, $channel, $throttle); + } + /** * Create a once property. */ @@ -359,6 +371,23 @@ public function render(mixed $component, mixed $props = []): Response return $response; } + /** + * Resolve props for a broadcast payload. + * + * @param array $props + * @return array{__inertia: array{props: array}} + */ + public function broadcastProps(array $props): array + { + $resolver = new PropsResolver(request(), ''); + + return [ + '__inertia' => [ + 'props' => $resolver->resolveBroadcastProps($props), + ], + ]; + } + /** * Create an Inertia location response. */ diff --git a/src/inertia/src/SupportsLiveUpdates.php b/src/inertia/src/SupportsLiveUpdates.php new file mode 100644 index 0000000000..c6950a3f9d --- /dev/null +++ b/src/inertia/src/SupportsLiveUpdates.php @@ -0,0 +1,35 @@ +|class-string|object|string $on + * @param null|array|Channel|string $channel + */ + public function live(array|object|string|null $on = null, array|Channel|string|null $channel = null, ?int $throttle = null): static; + + /** + * Determine if the property should receive live updates. + */ + public function isLive(): bool; + + /** + * Get the live-update listeners that should refresh the property. + * + * @return array}> + */ + public function liveListeners(): array; + + /** + * Get the number of milliseconds to throttle live updates by. + */ + public function liveThrottle(): ?int; +} diff --git a/src/mcp/src/Client/Middleware/RestrictHeadersToOrigin.php b/src/mcp/src/Client/Middleware/RestrictHeadersToOrigin.php index 789219a556..07652e6726 100644 --- a/src/mcp/src/Client/Middleware/RestrictHeadersToOrigin.php +++ b/src/mcp/src/Client/Middleware/RestrictHeadersToOrigin.php @@ -12,7 +12,7 @@ use Psr\Http\Message\UriInterface; /** - * Keep custom credentials on their configured origin while still following redirects. + * Keep custom credentials removed after a redirect leaves their configured origin. * * Guzzle strips Authorization and Cookie, but other headers can also carry credentials. * Laravel MCP forwards those headers; redirects remain supported here without exposing them. @@ -32,12 +32,17 @@ public function __construct(string $url, protected readonly array $headers) } /** - * Remove custom headers before a request leaves their origin. + * Remove custom headers for the rest of a cross-origin redirect chain. */ public function __invoke(callable $handler): Closure { - return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { - if (UriComparator::isCrossOrigin($this->origin, $request->getUri())) { + // Callers create a fresh pending request per operation, so this state belongs to one redirect chain. + $leftOrigin = false; + + return function (RequestInterface $request, array $options) use ($handler, &$leftOrigin): PromiseInterface { + $leftOrigin = $leftOrigin || UriComparator::isCrossOrigin($this->origin, $request->getUri()); + + if ($leftOrigin) { foreach ($this->headers as $name) { $request = $request->withoutHeader($name); } diff --git a/src/notifications/src/HasDatabaseNotifications.php b/src/notifications/src/HasDatabaseNotifications.php index 06460aa64e..c1e3b090fe 100644 --- a/src/notifications/src/HasDatabaseNotifications.php +++ b/src/notifications/src/HasDatabaseNotifications.php @@ -26,7 +26,7 @@ public function notifications(): MorphMany */ public function readNotifications(): MorphMany { - return $this->notifications()->read(); /* @phpstan-ignore method.notFound */ + return $this->notifications()->read(); } /** @@ -36,6 +36,6 @@ public function readNotifications(): MorphMany */ public function unreadNotifications(): MorphMany { - return $this->notifications()->unread(); /* @phpstan-ignore method.notFound */ + return $this->notifications()->unread(); } } diff --git a/src/opentelemetry/composer.json b/src/opentelemetry/composer.json index 54e784ad97..925e310ed3 100644 --- a/src/opentelemetry/composer.json +++ b/src/opentelemetry/composer.json @@ -68,7 +68,7 @@ "open-telemetry/context": "^1.5", "open-telemetry/exporter-otlp": "^1.4", "open-telemetry/sdk": "^1.15", - "open-telemetry/sem-conv": "^1.38", + "open-telemetry/sem-conv": "^1.45", "psr/clock": "^1.0", "psr/http-client": "^1.0", "psr/http-message": "^2.0", diff --git a/src/opentelemetry/config/opentelemetry.php b/src/opentelemetry/config/opentelemetry.php index a09dd81ce0..bb84267f76 100644 --- a/src/opentelemetry/config/opentelemetry.php +++ b/src/opentelemetry/config/opentelemetry.php @@ -193,10 +193,10 @@ PoolInstrumentation::class => [ 'metrics' => [ 'db.client.connection.count' => true, - 'db.client.connection.max' => true, + 'db.client.connection.limit' => true, 'db.client.connection.pending_requests' => true, 'hypervel.object_pool.objects' => true, - 'hypervel.object_pool.max' => true, + 'hypervel.object_pool.limit' => true, 'hypervel.object_pool.pending_requests' => true, ], ], diff --git a/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php b/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php index 22dbc18ae8..05248bda14 100644 --- a/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/AbstractInstrumentation.php @@ -7,6 +7,7 @@ use Hypervel\OpenTelemetry\Contracts\Instrumentation; use Hypervel\OpenTelemetry\Support\InstrumentationOptions; use OpenTelemetry\API\Metrics\ObserverInterface; +use OpenTelemetry\SemConv\Version; abstract class AbstractInstrumentation implements Instrumentation { @@ -42,6 +43,16 @@ protected function metricEnabled(string $name): bool return $this->options->metricEnabled($name); } + /** + * Return the semantic conventions schema URL for Hypervel instrumentation scopes. + * + * Every tracer, meter and logger obtained for one scope must pass the same URL. + */ + protected function schemaUrl(): string + { + return Version::VERSION_1_45_0->url(); + } + /** * Index callback observers by their matching instrument name. * diff --git a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php index 1a5c31cf6c..5267073d2f 100644 --- a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php @@ -69,7 +69,7 @@ protected function registerInstrumentation(): void if ($this->metricEnabled(self::OPERATIONS_METRIC)) { $this->operations = $this->meterProvider - ->getMeter('hypervel.cache') + ->getMeter('hypervel.cache', schemaUrl: $this->schemaUrl()) ->createCounter( self::OPERATIONS_METRIC, '{operation}', diff --git a/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php b/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php index 3bfbe1ded4..30e01fb852 100644 --- a/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ConsoleInstrumentation.php @@ -96,12 +96,12 @@ protected function registerInstrumentation(): void $this->excludedCommands = $excludedCommands; if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.console'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.console', schemaUrl: $this->schemaUrl()); } if ($this->metricEnabled(self::DURATION_METRIC)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.console') + ->getMeter('hypervel.console', schemaUrl: $this->schemaUrl()) ->createHistogram( self::DURATION_METRIC, 's', diff --git a/src/opentelemetry/src/Instrumentation/DatabaseInstrumentation.php b/src/opentelemetry/src/Instrumentation/DatabaseInstrumentation.php index 3a73591da8..56aab7c21a 100644 --- a/src/opentelemetry/src/Instrumentation/DatabaseInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/DatabaseInstrumentation.php @@ -22,7 +22,6 @@ use OpenTelemetry\SemConv\Attributes\DbAttributes; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\DbIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\DbMetrics; use Throwable; @@ -42,6 +41,10 @@ class DatabaseInstrumentation extends AbstractInstrumentation protected const string CONNECTION_ROLE_ATTRIBUTE = 'hypervel.db.connection.role'; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string DB_SYSTEM_NAME_SQLITE = 'sqlite'; + protected ?TracerInterface $tracer = null; protected ?HistogramInterface $duration = null; @@ -70,7 +73,7 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.database'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.database', schemaUrl: $this->schemaUrl()); $this->queryText = $this->options->enabled('query_text'); /** @var null|int $queryTextMaxLength */ $queryTextMaxLength = $this->options->get('query_text_max_length'); @@ -79,7 +82,7 @@ protected function registerInstrumentation(): void if ($this->metricEnabled(DbMetrics::DB_CLIENT_OPERATION_DURATION)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.database') + ->getMeter('hypervel.database', schemaUrl: $this->schemaUrl()) ->createHistogram( DbMetrics::DB_CLIENT_OPERATION_DURATION, 's', @@ -258,7 +261,7 @@ protected function systemName(string $driver): string 'mysql' => DbAttributes::DB_SYSTEM_NAME_VALUE_MYSQL, 'mariadb' => DbAttributes::DB_SYSTEM_NAME_VALUE_MARIADB, 'pgsql' => DbAttributes::DB_SYSTEM_NAME_VALUE_POSTGRESQL, - 'sqlite' => DbIncubatingAttributes::DB_SYSTEM_NAME_VALUE_SQLITE, + 'sqlite' => self::DB_SYSTEM_NAME_SQLITE, default => $driver, }; } diff --git a/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php b/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php index 45cce0e991..9cd245372b 100644 --- a/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php @@ -67,7 +67,7 @@ protected function registerInstrumentation(): void $logsEnabled = $this->options->enabled('logs'); if ($logsEnabled) { - $this->logger = $this->loggerProvider->getLogger('hypervel.exceptions'); + $this->logger = $this->loggerProvider->getLogger('hypervel.exceptions', schemaUrl: $this->schemaUrl()); $this->message = $this->options->enabled('message'); $this->stackTrace = $this->options->enabled('stack_trace'); $this->exceptionContexts->enable(); @@ -78,7 +78,7 @@ protected function registerInstrumentation(): void if ($this->metricEnabled(self::EXCEPTIONS_METRIC)) { $this->exceptions = $this->meterProvider - ->getMeter('hypervel.exceptions') + ->getMeter('hypervel.exceptions', schemaUrl: $this->schemaUrl()) ->createCounter( self::EXCEPTIONS_METRIC, '{exception}', diff --git a/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php b/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php index 6c1d55e38d..64cde985be 100644 --- a/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/GrpcInstrumentation.php @@ -29,6 +29,7 @@ use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; +use OpenTelemetry\SemConv\Attributes\RpcAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; use Swoole\Coroutine\CanceledException; use Throwable; @@ -39,14 +40,6 @@ class GrpcInstrumentation extends AbstractInstrumentation implements GrpcOperati protected const string SERVER_DURATION_METRIC = 'rpc.server.call.duration'; - protected const string RPC_SYSTEM_ATTRIBUTE = 'rpc.system.name'; - - protected const string RPC_METHOD_ATTRIBUTE = 'rpc.method'; - - protected const string RPC_STATUS_ATTRIBUTE = 'rpc.response.status_code'; - - protected const string RPC_SYSTEM_GRPC = 'grpc'; - protected const string RPC_METHOD_OTHER = '_OTHER'; protected const string ATTEMPT_COUNT_ATTRIBUTE = 'hypervel.grpc.attempt_count'; @@ -99,12 +92,12 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.grpc'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.grpc', schemaUrl: $this->schemaUrl()); } if ($this->metricEnabled(self::CLIENT_DURATION_METRIC)) { $this->clientDuration = $this->meterProvider - ->getMeter('hypervel.grpc') + ->getMeter('hypervel.grpc', schemaUrl: $this->schemaUrl()) ->createHistogram( self::CLIENT_DURATION_METRIC, 's', @@ -115,7 +108,7 @@ protected function registerInstrumentation(): void if ($this->metricEnabled(self::SERVER_DURATION_METRIC)) { $this->serverDuration = $this->meterProvider - ->getMeter('hypervel.grpc') + ->getMeter('hypervel.grpc', schemaUrl: $this->schemaUrl()) ->createHistogram( self::SERVER_DURATION_METRIC, 's', @@ -176,7 +169,7 @@ protected function startClient(ClientGrpcOperation $operation): GrpcTelemetrySta if ($this->tracer !== null) { $span = $this->tracer - ->spanBuilder($attributes[self::RPC_METHOD_ATTRIBUTE]) + ->spanBuilder($attributes[RpcAttributes::RPC_METHOD]) ->setSpanKind(SpanKind::KIND_CLIENT) ->setParent($parent) ->setStartTimestamp($startedAt) @@ -240,9 +233,9 @@ protected function startServer(ServerGrpcOperation $operation): GrpcTelemetrySta : []; if ($this->tracer !== null) { - $spanName = $sharedAttributes[self::RPC_METHOD_ATTRIBUTE]; + $spanName = $sharedAttributes[RpcAttributes::RPC_METHOD]; $span = $this->tracer - ->spanBuilder($spanName === self::RPC_METHOD_OTHER ? self::RPC_SYSTEM_GRPC : $spanName) + ->spanBuilder($spanName === self::RPC_METHOD_OTHER ? RpcAttributes::RPC_SYSTEM_NAME_VALUE_GRPC : $spanName) ->setSpanKind(SpanKind::KIND_SERVER) ->setParent($parent) ->setStartTimestamp($startedAt) @@ -293,7 +286,7 @@ protected function complete( $errorType = $this->errorType($operation, $status, $statusName, $result->exception); if ($statusName !== null) { - $metricAttributes[self::RPC_STATUS_ATTRIBUTE] = $statusName; + $metricAttributes[RpcAttributes::RPC_STATUS_CODE] = $statusName; } if ($errorType !== null) { @@ -304,7 +297,7 @@ protected function complete( try { if ($spanRecording) { if ($statusName !== null) { - $state->span->setAttribute(self::RPC_STATUS_ATTRIBUTE, $statusName); + $state->span->setAttribute(RpcAttributes::RPC_STATUS_CODE, $statusName); } if ($operation instanceof ClientGrpcOperation) { @@ -349,8 +342,8 @@ protected function complete( protected function sharedAttributes(GrpcOperation $operation): array { return [ - self::RPC_SYSTEM_ATTRIBUTE => self::RPC_SYSTEM_GRPC, - self::RPC_METHOD_ATTRIBUTE => $this->method($operation), + RpcAttributes::RPC_SYSTEM_NAME => RpcAttributes::RPC_SYSTEM_NAME_VALUE_GRPC, + RpcAttributes::RPC_METHOD => $this->method($operation), ]; } diff --git a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php index a737b50478..b897ca2564 100644 --- a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php @@ -33,8 +33,6 @@ use OpenTelemetry\SemConv\Attributes\NetworkAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; use OpenTelemetry\SemConv\Attributes\UrlAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\HttpIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\UrlIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\HttpMetrics; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -66,6 +64,10 @@ class HttpClientInstrumentation extends AbstractInstrumentation protected const string REDIRECT_COUNT_OPTION = '__redirect_count'; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string URL_TEMPLATE = 'url.template'; + /** @var array */ protected array $knownMethods = []; @@ -121,12 +123,12 @@ protected function registerInstrumentation(): void ); if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.http.client'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.http.client', schemaUrl: $this->schemaUrl()); } if ($this->metricEnabled(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.http.client') + ->getMeter('hypervel.http.client', schemaUrl: $this->schemaUrl()) ->createHistogram( HttpMetrics::HTTP_CLIENT_REQUEST_DURATION, 's', @@ -329,7 +331,7 @@ protected function requestTraceAttributes( UrlAttributes::URL_FULL => $this->httpAttributes->fullUrl($uri), UrlAttributes::URL_SCHEME => $uri->getScheme(), HttpAttributes::HTTP_REQUEST_RESEND_COUNT => $resendCount > 0 ? $resendCount : null, - HttpIncubatingAttributes::HTTP_REQUEST_BODY_SIZE => $this->httpAttributes->contentLength( + HttpTelemetryAttributes::REQUEST_BODY_SIZE => $this->httpAttributes->contentLength( $request->getHeaderLine('Content-Length'), ), ], @@ -372,7 +374,7 @@ protected function applyUrlTemplate( } if ($template !== null) { - $span->setAttribute(UrlIncubatingAttributes::URL_TEMPLATE, $template); + $span->setAttribute(self::URL_TEMPLATE, $template); $span->updateName($this->httpAttributes->spanName( $methodAttributes[HttpAttributes::HTTP_REQUEST_METHOD], $template, @@ -404,7 +406,7 @@ protected function completeResponse( $span->setAttributes(array_replace( $attributes, array_filter([ - HttpIncubatingAttributes::HTTP_RESPONSE_BODY_SIZE => $this->httpAttributes->contentLength( + HttpTelemetryAttributes::RESPONSE_BODY_SIZE => $this->httpAttributes->contentLength( $response->getHeaderLine('Content-Length'), ), ], static fn (mixed $value): bool => $value !== null), diff --git a/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php index 641ff6699f..b2639e0ee5 100644 --- a/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/HttpServerInstrumentation.php @@ -36,8 +36,6 @@ use OpenTelemetry\SemConv\Attributes\NetworkAttributes; use OpenTelemetry\SemConv\Attributes\UrlAttributes; use OpenTelemetry\SemConv\Attributes\UserAgentAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\HttpIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Metrics\HttpIncubatingMetrics; use OpenTelemetry\SemConv\Metrics\HttpMetrics; use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; @@ -62,6 +60,10 @@ class HttpServerInstrumentation extends AbstractInstrumentation 10, ]; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string ACTIVE_REQUESTS_METRIC = 'http.server.active_requests'; + /** @var array */ protected array $knownMethods = []; @@ -115,7 +117,7 @@ protected function registerInstrumentation(): void $this->userContext = $this->options->enabled('user_context'); $tracesEnabled = $this->tracesEnabled(); $durationEnabled = $this->metricEnabled(HttpMetrics::HTTP_SERVER_REQUEST_DURATION); - $activeRequestsEnabled = $this->metricEnabled(HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS); + $activeRequestsEnabled = $this->metricEnabled(self::ACTIVE_REQUESTS_METRIC); $this->userContextOnly = ! $tracesEnabled && ! $durationEnabled && ! $activeRequestsEnabled @@ -150,11 +152,11 @@ protected function registerInstrumentation(): void ); if ($tracesEnabled) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.http.server'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.http.server', schemaUrl: $this->schemaUrl()); } $meter = $durationEnabled || $activeRequestsEnabled - ? $this->meterProvider->getMeter('hypervel.http.server') + ? $this->meterProvider->getMeter('hypervel.http.server', schemaUrl: $this->schemaUrl()) : null; if ($durationEnabled) { @@ -168,7 +170,7 @@ protected function registerInstrumentation(): void if ($activeRequestsEnabled) { $this->activeRequests = $meter->createUpDownCounter( - HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS, + self::ACTIVE_REQUESTS_METRIC, '{request}', 'Number of active HTTP server requests.', ); @@ -419,7 +421,7 @@ protected function requestTraceAttributes(Request $request, array $methodAttribu ), ClientAttributes::CLIENT_ADDRESS => $request->getClientIp(), UserAgentAttributes::USER_AGENT_ORIGINAL => $request->headers->get('User-Agent'), - HttpIncubatingAttributes::HTTP_REQUEST_BODY_SIZE => $this->httpAttributes->contentLength( + HttpTelemetryAttributes::REQUEST_BODY_SIZE => $this->httpAttributes->contentLength( $request->headers->get('Content-Length'), ), ], @@ -466,7 +468,7 @@ protected function responseTraceAttributes(Response $response): array } return array_filter(array_replace( - [HttpIncubatingAttributes::HTTP_RESPONSE_BODY_SIZE => $contentLength], + [HttpTelemetryAttributes::RESPONSE_BODY_SIZE => $contentLength], $this->httpAttributes->responseHeaderAttributes($response->headers->all()), ), static fn (mixed $value): bool => $value !== null); } diff --git a/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php b/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php index de1273562f..8c0dabdc74 100644 --- a/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/PoolInstrumentation.php @@ -12,14 +12,28 @@ use OpenTelemetry\API\Metrics\MeterProviderInterface; use OpenTelemetry\API\Metrics\ObservableUpDownCounterInterface; use OpenTelemetry\API\Metrics\ObserverInterface; -use OpenTelemetry\SemConv\Incubating\Attributes\DbIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Metrics\DbIncubatingMetrics; class PoolInstrumentation extends AbstractInstrumentation { + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string CONNECTION_COUNT_METRIC = 'db.client.connection.count'; + + protected const string CONNECTION_LIMIT_METRIC = 'db.client.connection.limit'; + + protected const string CONNECTION_PENDING_REQUESTS_METRIC = 'db.client.connection.pending_requests'; + + protected const string CONNECTION_POOL_NAME_ATTRIBUTE = 'db.client.connection.pool.name'; + + protected const string CONNECTION_STATE_ATTRIBUTE = 'db.client.connection.state'; + + protected const string STATE_IDLE = 'idle'; + + protected const string STATE_USED = 'used'; + protected const string OBJECTS_METRIC = 'hypervel.object_pool.objects'; - protected const string OBJECTS_MAX_METRIC = 'hypervel.object_pool.max'; + protected const string OBJECTS_LIMIT_METRIC = 'hypervel.object_pool.limit'; protected const string OBJECTS_PENDING_REQUESTS_METRIC = 'hypervel.object_pool.pending_requests'; @@ -44,13 +58,13 @@ public function __construct( protected function registerInstrumentation(): void { $connectionMetrics = array_values(array_filter([ - DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT, - DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX, - DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS, + self::CONNECTION_COUNT_METRIC, + self::CONNECTION_LIMIT_METRIC, + self::CONNECTION_PENDING_REQUESTS_METRIC, ], fn (string $name): bool => $this->metricEnabled($name))); $objectMetrics = array_values(array_filter([ self::OBJECTS_METRIC, - self::OBJECTS_MAX_METRIC, + self::OBJECTS_LIMIT_METRIC, self::OBJECTS_PENDING_REQUESTS_METRIC, ], fn (string $name): bool => $this->metricEnabled($name))); @@ -58,7 +72,7 @@ protected function registerInstrumentation(): void return; } - $meter = $this->meterProvider->getMeter('hypervel.pools'); + $meter = $this->meterProvider->getMeter('hypervel.pools', schemaUrl: $this->schemaUrl()); if ($connectionMetrics !== []) { $instruments = array_map( @@ -94,20 +108,20 @@ function (ObserverInterface ...$observers) use ($objectMetrics): void { /** * Create one observable pool instrument. * - * @param DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT|DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX|DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS|self::OBJECTS_MAX_METRIC|self::OBJECTS_METRIC|self::OBJECTS_PENDING_REQUESTS_METRIC $name + * @param self::CONNECTION_COUNT_METRIC|self::CONNECTION_LIMIT_METRIC|self::CONNECTION_PENDING_REQUESTS_METRIC|self::OBJECTS_LIMIT_METRIC|self::OBJECTS_METRIC|self::OBJECTS_PENDING_REQUESTS_METRIC $name */ protected function createInstrument(MeterInterface $meter, string $name): ObservableUpDownCounterInterface { [$unit, $description] = match ($name) { - DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT => [ + self::CONNECTION_COUNT_METRIC => [ '{connection}', 'The number of connections that are currently in each state.', ], - DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX => [ + self::CONNECTION_LIMIT_METRIC => [ '{connection}', 'The maximum number of open connections allowed.', ], - DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS => [ + self::CONNECTION_PENDING_REQUESTS_METRIC => [ '{request}', 'The number of requests waiting for an open connection.', ], @@ -115,7 +129,7 @@ protected function createInstrument(MeterInterface $meter, string $name): Observ '{object}', 'The number of pooled objects that are currently in each state.', ], - self::OBJECTS_MAX_METRIC => [ + self::OBJECTS_LIMIT_METRIC => [ '{object}', 'The maximum number of objects allowed in the pool.', ], @@ -151,29 +165,29 @@ protected function observeConnectionPools(array $observers): void */ protected function observeConnectionPool(array $observers, ConnectionPool $pool, string $name): void { - $attributes = [DbIncubatingAttributes::DB_CLIENT_CONNECTION_POOL_NAME => $name]; + $attributes = [self::CONNECTION_POOL_NAME_ATTRIBUTE => $name]; - if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT])) { + if (isset($observers[self::CONNECTION_COUNT_METRIC])) { $idle = $pool->getIdleCount(); $used = $pool->getManagedCount() - $idle; - $observer = $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT]; + $observer = $observers[self::CONNECTION_COUNT_METRIC]; $observer->observe($idle, $attributes + [ - DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_IDLE, + self::CONNECTION_STATE_ATTRIBUTE => self::STATE_IDLE, ]); $observer->observe($used, $attributes + [ - DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_USED, + self::CONNECTION_STATE_ATTRIBUTE => self::STATE_USED, ]); } - if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX])) { - $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX]->observe( + if (isset($observers[self::CONNECTION_LIMIT_METRIC])) { + $observers[self::CONNECTION_LIMIT_METRIC]->observe( $pool->getOptions()->maxConnections, $attributes, ); } - if (isset($observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS])) { - $observers[DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS]->observe( + if (isset($observers[self::CONNECTION_PENDING_REQUESTS_METRIC])) { + $observers[self::CONNECTION_PENDING_REQUESTS_METRIC]->observe( $pool->getWaitingCount(), $attributes, ); @@ -195,15 +209,15 @@ protected function observeObjectPools(array $observers): void $stats = $pool->getStats(); $observer = $observers[self::OBJECTS_METRIC]; $observer->observe($stats['idle'], $attributes + [ - self::OBJECT_POOL_STATE_ATTRIBUTE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_IDLE, + self::OBJECT_POOL_STATE_ATTRIBUTE => self::STATE_IDLE, ]); $observer->observe($stats['managed'] - $stats['idle'], $attributes + [ - self::OBJECT_POOL_STATE_ATTRIBUTE => DbIncubatingAttributes::DB_CLIENT_CONNECTION_STATE_VALUE_USED, + self::OBJECT_POOL_STATE_ATTRIBUTE => self::STATE_USED, ]); } - if (isset($observers[self::OBJECTS_MAX_METRIC])) { - $observers[self::OBJECTS_MAX_METRIC]->observe( + if (isset($observers[self::OBJECTS_LIMIT_METRIC])) { + $observers[self::OBJECTS_LIMIT_METRIC]->observe( $pool->getOptions()->maxObjects, $attributes, ); diff --git a/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php b/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php index a310634277..ff216553dd 100644 --- a/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/QueueInstrumentation.php @@ -43,7 +43,6 @@ use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; use OpenTelemetry\Context\Propagation\TextMapPropagatorInterface; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\MessagingIncubatingAttributes; use Swoole\Coroutine\CanceledException; use Throwable; use WeakMap; @@ -52,6 +51,8 @@ class QueueInstrumentation extends AbstractInstrumentation { use LogsMessagesTrait; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. protected const string SENT_MESSAGES_METRIC = 'messaging.client.sent.messages'; protected const string SEND_DURATION_METRIC = 'messaging.client.operation.duration'; @@ -60,6 +61,24 @@ class QueueInstrumentation extends AbstractInstrumentation protected const string PROCESS_DURATION_METRIC = 'messaging.process.duration'; + protected const string SYSTEM_NAME_ATTRIBUTE = 'messaging.system.name'; + + protected const string DESTINATION_NAME_ATTRIBUTE = 'messaging.destination.name'; + + protected const string OPERATION_NAME_ATTRIBUTE = 'messaging.operation.name'; + + protected const string OPERATION_TYPE_ATTRIBUTE = 'messaging.operation.type'; + + protected const string MESSAGE_ID_ATTRIBUTE = 'messaging.message.id'; + + protected const string MESSAGE_ENVELOPE_SIZE_ATTRIBUTE = 'messaging.message.envelope.size'; + + protected const string OPERATION_TYPE_SEND = 'send'; + + protected const string OPERATION_TYPE_PROCESS = 'process'; + + protected const string SYSTEM_NAME_AWS_SQS = 'aws.sqs'; + protected const string DEPTH_METRIC = 'hypervel.queue.jobs'; protected const string OUTBOX_DELIVERED_METRIC = 'hypervel.queue.outbox.delivered.messages'; @@ -141,7 +160,7 @@ protected function registerInstrumentation(): void $this->propagation = $this->options->enabled('propagation'); if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.queue'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.queue', schemaUrl: $this->schemaUrl()); } $this->registerMetrics(); @@ -218,7 +237,7 @@ protected function registerMetrics(): void $meter = null; if ($this->metricEnabled(self::SENT_MESSAGES_METRIC)) { - $meter = $this->meterProvider->getMeter('hypervel.queue'); + $meter = $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->sentMessages = $meter->createCounter( self::SENT_MESSAGES_METRIC, '{message}', @@ -227,7 +246,7 @@ protected function registerMetrics(): void } if ($this->metricEnabled(self::SEND_DURATION_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.queue'); + $meter ??= $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->sendDuration = $meter->createHistogram( self::SEND_DURATION_METRIC, 's', @@ -237,7 +256,7 @@ protected function registerMetrics(): void } if ($this->metricEnabled(self::CONSUMED_MESSAGES_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.queue'); + $meter ??= $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->consumedMessages = $meter->createCounter( self::CONSUMED_MESSAGES_METRIC, '{message}', @@ -246,7 +265,7 @@ protected function registerMetrics(): void } if ($this->metricEnabled(self::PROCESS_DURATION_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.queue'); + $meter ??= $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->processDuration = $meter->createHistogram( self::PROCESS_DURATION_METRIC, 's', @@ -264,12 +283,12 @@ protected function registerOutboxMetrics(): void $meter = null; if ($this->metricEnabled(self::OUTBOX_DELIVERED_METRIC)) { - $meter = $this->meterProvider->getMeter('hypervel.queue'); + $meter = $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->outboxDelivered = $meter->createCounter(self::OUTBOX_DELIVERED_METRIC, '{message}', 'Outbox messages confirmed by their destination queue.'); } if ($this->metricEnabled(self::OUTBOX_DURATION_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.queue'); + $meter ??= $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->outboxDuration = $meter->createHistogram( self::OUTBOX_DURATION_METRIC, 's', @@ -279,7 +298,7 @@ protected function registerOutboxMetrics(): void } if ($this->metricEnabled(self::OUTBOX_LATENCY_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.queue'); + $meter ??= $this->meterProvider->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()); $this->outboxLatency = $meter->createHistogram( self::OUTBOX_LATENCY_METRIC, 's', @@ -333,7 +352,7 @@ protected function finalizePayload(JobPayloadFinalizing $event): void $event->connectionName, $event->queue, 'enqueue', - MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE_VALUE_SEND, + self::OPERATION_TYPE_SEND, ) : []; $startedAt = 0; @@ -367,11 +386,11 @@ protected function finalizePayload(JobPayloadFinalizing $event): void if ($span?->isRecording()) { if ($uuid !== null) { - $span->setAttribute(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID, $uuid); + $span->setAttribute(self::MESSAGE_ID_ATTRIBUTE, $uuid); } $span->setAttribute( - MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE, + self::MESSAGE_ENVELOPE_SIZE_ATTRIBUTE, strlen($finalPayload), ); } @@ -527,7 +546,7 @@ protected function startConsumer(JobProcessing $event): void $event->connectionName, $queue, 'process', - MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE_VALUE_PROCESS, + self::OPERATION_TYPE_PROCESS, ); } @@ -554,11 +573,11 @@ protected function startConsumer(JobProcessing $event): void if ($span->isRecording()) { if (($uuid = $event->job->uuid()) !== null) { - $span->setAttribute(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID, $uuid); + $span->setAttribute(self::MESSAGE_ID_ATTRIBUTE, $uuid); } $span->setAttribute( - MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE, + self::MESSAGE_ENVELOPE_SIZE_ATTRIBUTE, strlen($event->job->getRawBody()), ); } @@ -684,7 +703,7 @@ protected function registerDepthMetric(): void /** @var array> $targets */ $targets = $this->options->get('depth_queues'); $this->meterProvider - ->getMeter('hypervel.queue') + ->getMeter('hypervel.queue', schemaUrl: $this->schemaUrl()) ->createObservableGauge( self::DEPTH_METRIC, '{job}', @@ -709,7 +728,7 @@ function (ObserverInterface $observer) use ($targets): void { $observer->observe($size, [ self::CONNECTION_ATTRIBUTE => $connection, - MessagingIncubatingAttributes::MESSAGING_DESTINATION_NAME => $queue, + self::DESTINATION_NAME_ATTRIBUTE => $queue, ]); } } @@ -729,10 +748,10 @@ protected function attributes( string $operationType, ): array { return array_filter([ - MessagingIncubatingAttributes::MESSAGING_SYSTEM => $this->connectionDrivers[$connection] ?? 'unknown', - MessagingIncubatingAttributes::MESSAGING_DESTINATION_NAME => $queue, - MessagingIncubatingAttributes::MESSAGING_OPERATION_NAME => $operation, - MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE => $operationType, + self::SYSTEM_NAME_ATTRIBUTE => $this->connectionDrivers[$connection] ?? 'unknown', + self::DESTINATION_NAME_ATTRIBUTE => $queue, + self::OPERATION_NAME_ATTRIBUTE => $operation, + self::OPERATION_TYPE_ATTRIBUTE => $operationType, self::CONNECTION_ATTRIBUTE => $connection, ], static fn (?string $value): bool => $value !== null); } @@ -752,7 +771,7 @@ protected function connectionDrivers(): array if (is_string($connection) && is_string($driver)) { $drivers[$connection] = match ($driver) { 'beanstalkd' => 'beanstalk', - 'sqs' => MessagingIncubatingAttributes::MESSAGING_SYSTEM_VALUE_AWS_SQS, + 'sqs' => self::SYSTEM_NAME_AWS_SQS, default => $driver, }; } diff --git a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php index a79c21a04b..226f217a98 100644 --- a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php @@ -25,7 +25,6 @@ use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\Attributes\DbAttributes; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\DbIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\DbMetrics; use Throwable; use UnexpectedValueException; @@ -36,6 +35,10 @@ class RedisInstrumentation extends AbstractInstrumentation protected const string CONNECTION_ATTRIBUTE = 'hypervel.redis.connection'; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string DB_SYSTEM_NAME_REDIS = 'redis'; + protected const array DURATION_BOUNDARIES = [ 0.001, 0.005, @@ -79,7 +82,7 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.redis'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.redis', schemaUrl: $this->schemaUrl()); $this->queryText = $this->options->enabled('query_text'); /** @var null|int $queryTextMaxLength */ $queryTextMaxLength = $this->options->get('query_text_max_length'); @@ -88,7 +91,7 @@ protected function registerInstrumentation(): void if ($this->metricEnabled(DbMetrics::DB_CLIENT_OPERATION_DURATION)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.redis') + ->getMeter('hypervel.redis', schemaUrl: $this->schemaUrl()) ->createHistogram( DbMetrics::DB_CLIENT_OPERATION_DURATION, 's', @@ -226,7 +229,7 @@ protected function recordCommand( protected function attributes(string $operation, string $connection, ?string $errorType): array { return array_filter([ - DbAttributes::DB_SYSTEM_NAME => DbIncubatingAttributes::DB_SYSTEM_NAME_VALUE_REDIS, + DbAttributes::DB_SYSTEM_NAME => self::DB_SYSTEM_NAME_REDIS, DbAttributes::DB_OPERATION_NAME => $operation, self::CONNECTION_ATTRIBUTE => $connection, ErrorAttributes::ERROR_TYPE => $errorType, diff --git a/src/opentelemetry/src/Instrumentation/RuntimeInstrumentation.php b/src/opentelemetry/src/Instrumentation/RuntimeInstrumentation.php index 2469d56494..8cd1a2b3d9 100644 --- a/src/opentelemetry/src/Instrumentation/RuntimeInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/RuntimeInstrumentation.php @@ -118,7 +118,7 @@ protected function registerInstrumentation(): void return; } - $meter = $this->meterProvider->getMeter('hypervel.runtime'); + $meter = $this->meterProvider->getMeter('hypervel.runtime', schemaUrl: $this->schemaUrl()); $this->registerBatch($meter, $memoryMetrics, $this->observeMemory(...)); $this->registerBatch($meter, $garbageCollectionMetrics, $this->observeGarbageCollection(...)); diff --git a/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php b/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php index 718f256036..be572df1e3 100644 --- a/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/SchedulerInstrumentation.php @@ -84,13 +84,13 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.scheduler'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.scheduler', schemaUrl: $this->schemaUrl()); } $meter = null; if ($this->metricEnabled(self::DURATION_METRIC)) { - $meter = $this->meterProvider->getMeter('hypervel.scheduler'); + $meter = $this->meterProvider->getMeter('hypervel.scheduler', schemaUrl: $this->schemaUrl()); $this->duration = $meter->createHistogram( self::DURATION_METRIC, 's', @@ -100,7 +100,7 @@ protected function registerInstrumentation(): void } if ($this->metricEnabled(self::EXECUTIONS_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.scheduler'); + $meter ??= $this->meterProvider->getMeter('hypervel.scheduler', schemaUrl: $this->schemaUrl()); $this->executions = $meter->createCounter( self::EXECUTIONS_METRIC, '{execution}', diff --git a/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php b/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php index 60657d3b2d..84fc6c3a33 100644 --- a/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ScoutInstrumentation.php @@ -61,12 +61,12 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.scout'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.scout', schemaUrl: $this->schemaUrl()); } if ($this->metricEnabled(DbMetrics::DB_CLIENT_OPERATION_DURATION)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.scout') + ->getMeter('hypervel.scout', schemaUrl: $this->schemaUrl()) ->createHistogram( DbMetrics::DB_CLIENT_OPERATION_DURATION, 's', diff --git a/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php b/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php index 15e513d4dd..b8b9267590 100644 --- a/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ViewInstrumentation.php @@ -68,12 +68,12 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.view'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.view', schemaUrl: $this->schemaUrl()); } if ($this->metricEnabled(self::DURATION_METRIC)) { $this->duration = $this->meterProvider - ->getMeter('hypervel.view') + ->getMeter('hypervel.view', schemaUrl: $this->schemaUrl()) ->createHistogram( self::DURATION_METRIC, 's', diff --git a/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php b/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php index 6951e440b6..f630b48f1f 100644 --- a/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/WebSocketInstrumentation.php @@ -85,13 +85,13 @@ public function __construct( protected function registerInstrumentation(): void { if ($this->tracesEnabled()) { - $this->tracer = $this->tracerProvider->getTracer('hypervel.websocket'); + $this->tracer = $this->tracerProvider->getTracer('hypervel.websocket', schemaUrl: $this->schemaUrl()); } $meter = null; if ($this->metricEnabled(self::DURATION_METRIC)) { - $meter = $this->meterProvider->getMeter('hypervel.websocket'); + $meter = $this->meterProvider->getMeter('hypervel.websocket', schemaUrl: $this->schemaUrl()); $this->duration = $meter->createHistogram( self::DURATION_METRIC, 's', @@ -101,7 +101,7 @@ protected function registerInstrumentation(): void } if ($this->metricEnabled(self::MESSAGES_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.websocket'); + $meter ??= $this->meterProvider->getMeter('hypervel.websocket', schemaUrl: $this->schemaUrl()); $this->messages = $meter->createCounter( self::MESSAGES_METRIC, '{message}', @@ -110,7 +110,7 @@ protected function registerInstrumentation(): void } if ($this->metricEnabled(self::ACTIVE_CONNECTIONS_METRIC)) { - $meter ??= $this->meterProvider->getMeter('hypervel.websocket'); + $meter ??= $this->meterProvider->getMeter('hypervel.websocket', schemaUrl: $this->schemaUrl()); $this->activeConnections = $meter->createUpDownCounter( self::ACTIVE_CONNECTIONS_METRIC, '{connection}', diff --git a/src/opentelemetry/src/ProviderFactory.php b/src/opentelemetry/src/ProviderFactory.php index 035d11b6cd..c3e3208795 100644 --- a/src/opentelemetry/src/ProviderFactory.php +++ b/src/opentelemetry/src/ProviderFactory.php @@ -40,10 +40,8 @@ use OpenTelemetry\SDK\Trace\SpanProcessorInterface; use OpenTelemetry\SDK\Trace\TracerProviderBuilder; use OpenTelemetry\SDK\Trace\TracerProviderInterface; +use OpenTelemetry\SemConv\Attributes\DeploymentAttributes; use OpenTelemetry\SemConv\Attributes\ServiceAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\DeploymentIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\ServiceIncubatingAttributes; -use OpenTelemetry\SemConv\Version; use RuntimeException; class ProviderFactory @@ -250,7 +248,7 @@ protected function resource(array $configuration, ProcessIdentity $identity): Re { $service = $configuration['service']; $explicit = $configuration['resource_attributes']; - $baseInstanceId = $explicit[ServiceIncubatingAttributes::SERVICE_INSTANCE_ID] + $baseInstanceId = $explicit[ServiceAttributes::SERVICE_INSTANCE_ID] ?? $service['instance_id'] ?? gethostname(); @@ -267,10 +265,10 @@ protected function resource(array $configuration, ProcessIdentity $identity): Re $attributes = array_filter([ ServiceAttributes::SERVICE_NAME => $service['name'], ServiceAttributes::SERVICE_VERSION => $service['version'], - DeploymentIncubatingAttributes::DEPLOYMENT_ENVIRONMENT_NAME => $service['environment'], + DeploymentAttributes::DEPLOYMENT_ENVIRONMENT_NAME => $service['environment'], ], static fn (mixed $value): bool => $value !== null); $attributes = array_replace($attributes, $explicit, $identity->resourceAttributes()); - $attributes[ServiceIncubatingAttributes::SERVICE_INSTANCE_ID] = sprintf( + $attributes[ServiceAttributes::SERVICE_INSTANCE_ID] = sprintf( '%s:%s:%s:%d', $baseInstanceId, $identity->type, @@ -278,10 +276,11 @@ protected function resource(array $configuration, ProcessIdentity $identity): Re $processId, ); - return ResourceInfoFactory::defaultResource()->merge(ResourceInfo::create( - Attributes::create($attributes), - Version::VERSION_1_38_0->url(), - )); + // Hypervel adds only stable and hypervel.* names, so it keeps the schema URL of the + // SDK's detected resource. A different URL would make the merge drop both. + return ResourceInfoFactory::defaultResource()->merge( + ResourceInfo::create(Attributes::create($attributes)), + ); } /** diff --git a/src/opentelemetry/src/Support/ConfigurationNormalizer.php b/src/opentelemetry/src/Support/ConfigurationNormalizer.php index 8c471e27fa..05d5a1f3e0 100644 --- a/src/opentelemetry/src/Support/ConfigurationNormalizer.php +++ b/src/opentelemetry/src/Support/ConfigurationNormalizer.php @@ -411,10 +411,10 @@ protected function instrumentationDefaults(): array PoolInstrumentation::class => [ 'metrics' => [ 'db.client.connection.count' => true, - 'db.client.connection.max' => true, + 'db.client.connection.limit' => true, 'db.client.connection.pending_requests' => true, 'hypervel.object_pool.objects' => true, - 'hypervel.object_pool.max' => true, + 'hypervel.object_pool.limit' => true, 'hypervel.object_pool.pending_requests' => true, ], ], diff --git a/src/opentelemetry/src/Support/HttpTelemetryAttributes.php b/src/opentelemetry/src/Support/HttpTelemetryAttributes.php index 8174f639e0..2949a403d5 100644 --- a/src/opentelemetry/src/Support/HttpTelemetryAttributes.php +++ b/src/opentelemetry/src/Support/HttpTelemetryAttributes.php @@ -9,6 +9,12 @@ class HttpTelemetryAttributes { + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + public const string REQUEST_BODY_SIZE = 'http.request.body.size'; + + public const string RESPONSE_BODY_SIZE = 'http.response.body.size'; + /** @var array */ protected array $sensitiveQueryParameters; diff --git a/src/opentelemetry/src/Support/UserContextResolver.php b/src/opentelemetry/src/Support/UserContextResolver.php index 2d9c591ab4..8f309ad71c 100644 --- a/src/opentelemetry/src/Support/UserContextResolver.php +++ b/src/opentelemetry/src/Support/UserContextResolver.php @@ -8,7 +8,6 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use OpenTelemetry\API\Behavior\LogsMessagesTrait; -use OpenTelemetry\SemConv\Incubating\Attributes\UserIncubatingAttributes; use Throwable; use Traversable; use UnexpectedValueException; @@ -17,6 +16,10 @@ class UserContextResolver { use LogsMessagesTrait; + // Development-status conventions are defined here rather than imported, because + // the sem-conv package removes renamed incubating constants in minor releases. + protected const string USER_ID_ATTRIBUTE = 'user.id'; + /** * Create an authenticated-user context resolver. */ @@ -50,7 +53,7 @@ public function resolve(RequestTelemetryState $state): array $identifier = $user->getAuthIdentifier(); if (is_int($identifier) || is_string($identifier)) { - $state->userAttributes = [UserIncubatingAttributes::USER_ID => (string) $identifier]; + $state->userAttributes = [self::USER_ID_ATTRIBUTE => (string) $identifier]; } return $state->userAttributes; diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php index b0c38d1ee4..48eb3fff3d 100644 --- a/src/saloon/src/Http/PendingRequest.php +++ b/src/saloon/src/Http/PendingRequest.php @@ -43,6 +43,9 @@ class PendingRequest use HasBody; use HasDebugging; use HasRequestProperties { + // HasHeaders must funnel public writes through these two methods for redirect protection. + withHeaders as protected addHeaders; + replaceHeaders as protected replaceRequestHeaders; withQueryParameters as protected addQueryParameters; withoutQueryParameters as protected removeQueryParameters; withQueryString as protected replaceQueryString; @@ -92,6 +95,18 @@ class PendingRequest */ protected array $psrRequestObservers = []; + /** + * The header names restricted to the request's origin. + * + * @var array + */ + protected array $authenticationHeaderNames = []; + + /** + * Whether header writes belong to an authenticator. + */ + protected bool $authenticating = false; + /** * The dedicated transport authentication option. * @@ -329,7 +344,7 @@ public function authenticate(Authenticator $authenticator): static // A later plugin may still change the URL or headers the authenticator reads, such as an API version in the // host, so applyAuthentication() applies the selected authenticator after the plugins boot. if (! $this->bootingPlugins) { - $authenticator->set($this); + $this->setAuthentication($authenticator); } return $this; @@ -342,11 +357,28 @@ public function authenticate(Authenticator $authenticator): static */ public function applyAuthentication(): static { - $this->authenticator?->set($this); + if ($this->authenticator !== null) { + $this->setAuthentication($this->authenticator); + } return $this; } + /** + * Apply an authenticator while recording its header writes. + */ + protected function setAuthentication(Authenticator $authenticator): void + { + $authenticating = $this->authenticating; + $this->authenticating = true; + + try { + $authenticator->set($this); + } finally { + $this->authenticating = $authenticating; + } + } + /** * Boot the connector and request plugins. * @@ -437,6 +469,59 @@ public function preparedBody(): ?StreamInterface return $this->preparedBody; } + /** + * Add headers to the request. + * + * @param array $headers + * @return $this + */ + public function withHeaders(array $headers): static + { + $this->recordAuthenticationHeaders($headers); + $this->addHeaders($headers); + + return $this; + } + + /** + * Replace matching headers on the request. + * + * @param array $headers + * @return $this + */ + public function replaceHeaders(array $headers): static + { + $this->recordAuthenticationHeaders($headers); + $this->replaceRequestHeaders($headers); + + return $this; + } + + /** + * Record headers written during authentication, including unchanged values. + * + * @param array $headers + */ + protected function recordAuthenticationHeaders(array $headers): void + { + if ($this->authenticating) { + foreach ($headers as $name => $value) { + $this->authenticationHeaderNames[strtolower($name)] = true; + } + } + } + + /** + * Get the authentication header names. + * + * @return list + * @internal + */ + public function authenticationHeaderNames(): array + { + return array_keys($this->authenticationHeaderNames); + } + /** * Set the dedicated transport authentication option. * diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php index 02161f3b7c..ec5e6aca11 100644 --- a/src/saloon/src/Http/Sender.php +++ b/src/saloon/src/Http/Sender.php @@ -5,6 +5,7 @@ namespace Hypervel\Saloon\Http; use GuzzleHttp\Cookie\SetCookie; +use GuzzleHttp\Psr7\UriComparator; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Telescope\TelescopeTag; use Hypervel\Http\Client\ConnectionException; @@ -66,9 +67,22 @@ public function send(PendingRequest $pendingRequest, array $transport): Response $httpRequest->withCookie(new SetCookie($cookie)); } + // Saloon creates a fresh sender invocation for each retry attempt. + $leftOrigin = false; + $httpRequest ->withHeaders(HeaderNormalizer::normalize($pendingRequest->headers())) - ->withRequestMiddleware(function (RequestInterface $request) use ($pendingRequest): RequestInterface { + ->withRequestMiddleware(function (RequestInterface $request) use ($pendingRequest, &$leftOrigin): RequestInterface { + // Upstream Saloon forwards custom authentication headers on redirects: Guzzle strips only + // Authorization and Cookie. Remove the remaining credentials before PSR hooks sign the request. + $leftOrigin = $leftOrigin || UriComparator::isCrossOrigin($pendingRequest->uri(), $request->getUri()); + + if ($leftOrigin) { + foreach ($pendingRequest->authenticationHeaderNames() as $name) { + $request = $request->withoutHeader($name); + } + } + $request = $pendingRequest->handlePsrRequest($request); $pendingRequest->notifyPsrRequestObservers($request); $pendingRequest->setPsrRequest($request); diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index 7b7fb8fea8..59ec9f1f0d 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -40,6 +40,7 @@ * @method static void authorizeChannelsUsing(\Closure|null $callback) * @method static void broadcast(array $channels, string $event, array $payload = []) * @method static \Hypervel\Broadcasting\Broadcasters\Broadcaster channel(\Hypervel\Contracts\Broadcasting\HasBroadcastChannel|string $channel, callable|string $callback, array $options = []) + * @method static \Closure|null channelFormatter() * @method static void flushState() * @method static void formatChannelsUsing(\Closure|null $callback) * @method static \Hypervel\Support\Collection getChannels() diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index a3d1317a01..ae6e9725db 100644 --- a/tests/Coroutine/CoroutineTest.php +++ b/tests/Coroutine/CoroutineTest.php @@ -227,6 +227,42 @@ public function testTerminalBoundaryReportsCleanupFailureDuringCancellationOnce( } } + #[TestWith([false])] + #[TestWith([true])] + public function testTerminalBoundaryContainsCancellationDuringReporting(bool $cleanupFails): void + { + $container = new Container; + $handler = m::mock(ExceptionHandlerContract::class); + $container->instance(ExceptionHandlerContract::class, $handler); + Container::setInstance($container); + $failure = new RuntimeException('The coroutine failed.'); + $cleanupFailure = new RuntimeException('The reporter cleanup failed.'); + $blocker = new Channel(1); + $handler->shouldReceive('report')->once()->with($failure) + ->andReturnUsing(static function () use ($blocker, $cleanupFails, $cleanupFailure): void { + try { + $blocker->pop(); + } finally { + if ($cleanupFails) { + throw $cleanupFailure; + } + } + }); + + $coroutineId = Coroutine::create(static fn () => throw $failure); + + try { + $this->assertTrue(EngineCoroutine::cancelById($coroutineId, throwException: true)); + $this->assertFalse(Coroutine::exists($coroutineId)); + if ($cleanupFails) { + $this->assertTrue(Coroutine::causedByCancellation($cleanupFailure)); + } + } finally { + $blocker->close(); + Coroutine::join([$coroutineId], 1); + } + } + public function testAfterCreatedCallbacksAreExecuted(): void { $executed = false; @@ -430,7 +466,9 @@ public function testAfterCreatedCallbackExceptionDoesNotStopOthers(): void $this->assertTrue($mainCallableRan); } - public function testOwnedCoroutineTransfersOwnershipBeforeStartupFailureReportingCanSuspend(): void + #[TestWith([false])] + #[TestWith([true])] + public function testOwnedCoroutineTransfersOwnershipBeforeStartupFailureReportingCanSuspend(bool $cleanupFails): void { $container = new Container; $handler = m::mock(ExceptionHandlerContract::class); @@ -438,6 +476,7 @@ public function testOwnedCoroutineTransfersOwnershipBeforeStartupFailureReportin Container::setInstance($container); CoroutineContext::set('request-id', 'child-request'); $exception = new RuntimeException('The startup hook failed.'); + $cleanupFailure = new RuntimeException('The reporter cleanup failed.'); $reportStarted = new Channel(1); $releaseReport = new Channel(1); $reportedContext = null; @@ -449,12 +488,22 @@ public function testOwnedCoroutineTransfersOwnershipBeforeStartupFailureReportin $handler->shouldReceive('report') ->once() ->with($exception) - ->andReturnUsing(static function () use ($reportStarted, $releaseReport, &$reportedContext): void { + ->andReturnUsing(static function () use ($reportStarted, $releaseReport, &$reportedContext, $cleanupFails, $cleanupFailure): void { $reportedContext = CoroutineContext::get('request-id'); $reportStarted->push(true); - $releaseReport->pop(); + try { + $releaseReport->pop(); + } finally { + if ($cleanupFails) { + throw $cleanupFailure; + } + } }); + if ($cleanupFails) { + $handler->shouldReceive('report')->once()->with($cleanupFailure); + } + Coroutine::afterCreated(static function () use ($exception): void { throw $exception; }); @@ -488,6 +537,11 @@ static function (Closure $run) use (&$started, &$finalized): void { $this->assertTrue(EngineCoroutine::cancelById($coroutineId, throwException: true)); $this->assertFalse(Coroutine::exists($coroutineId)); $this->assertSame(1, $finalized); + $this->assertFalse($secondCallbackRan); + $this->assertFalse($mainCallableRan); + if ($cleanupFails) { + $this->assertTrue(Coroutine::causedByCancellation($cleanupFailure)); + } } finally { $releaseReport->push(true, 0.001); diff --git a/tests/Data/Casts/UnserializeCastTest.php b/tests/Data/Casts/UnserializeCastTest.php new file mode 100644 index 0000000000..ff9d05f327 --- /dev/null +++ b/tests/Data/Casts/UnserializeCastTest.php @@ -0,0 +1,232 @@ +assertEquals( + DummyBackedEnum::FOO, + $cast->cast($this->property('enum'), $value, [], $this->context()), + ); + } + + public function testWillThrowAnExceptionWhenTheUnserializationFails(): void + { + $cast = new UnserializeCast; + + $this->expectException(ErrorException::class); + + $cast->cast($this->property('enum'), 'foo', [], $this->context()); + } + + public function testCanFailSilently(): void + { + $cast = new UnserializeCast(true); + + $this->assertInstanceOf( + Uncastable::class, + $cast->cast($this->property('enum'), 'foo', [], $this->context()), + ); + } + + public function testCanRestrictTheClassesWhichCanBeUnserialized(): void + { + $value = serialize(new SimpleData('Hello')); + + $context = $this->context(); + + $this->assertEquals( + new SimpleData('Hello'), + (new UnserializeCast(allowedClasses: [SimpleData::class]))->cast($this->property('data'), $value, [], $context), + ); + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + (new UnserializeCast(allowedClasses: []))->cast($this->property('data'), $value, [], $context), + ); + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + (new UnserializeCast(allowedClasses: false))->cast($this->property('data'), $value, [], $context), + ); + } + + public function testRestoresNoObjectsUnlessTheirClassesAreAllowed(): void + { + $value = serialize(new SimpleData('Hello')); + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + (new UnserializeCast)->cast($this->property('data'), $value, [], $this->context()), + ); + + $this->assertEquals( + new SimpleData('Hello'), + (new UnserializeCast(allowedClasses: true))->cast($this->property('data'), $value, [], $this->context()), + ); + } + + public function testADataObjectRestoresOnlyTheAllowedClasses(): void + { + $value = serialize(new SimpleData('Hello')); + + $this->assertEquals( + new SimpleData('Hello'), + UnserializeCastAllowedData::from(['data' => $value])->data, + ); + + $this->expectException(TypeError::class); + $this->expectExceptionMessage('__PHP_Incomplete_Class given'); + + UnserializeCastRestrictedData::from(['data' => $value]); + } + + public function testCancellationInARestoreHookIsNotTreatedAsAFailedCast(): void + { + $cast = new UnserializeCast(failSilently: true, allowedClasses: [UnserializeCastCanceledHook::class]); + + try { + $cast->cast($this->property('data'), serialize(new UnserializeCastCanceledHook), [], $this->context()); + $this->fail('Cancellation was treated as a failed cast.'); + } catch (CanceledException $exception) { + $this->assertSame('Canceled while restoring.', $exception->getMessage()); + } + + try { + $cast->cast($this->property('data'), serialize(new UnserializeCastCanceledHook(cleanupFails: true)), [], $this->context()); + $this->fail('Cancellation was treated as a failed cast after its cleanup failed.'); + } catch (LogicException $exception) { + $this->assertInstanceOf(CanceledException::class, $exception->getPrevious()); + } + } + + /** + * Build one property definition. + */ + protected function property(string $name): DataProperty + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver, new DataIterableAnnotationReader); + $reflectionClass = new ReflectionClass(UnserializeCastDataFixture::class); + $reflectionProperty = $reflectionClass->getProperty($name); + + return (new DataPropertyFactory( + $typeFactory, + $config, + new NameMapperResolver(new Container), + ))->build( + $reflectionProperty, + $reflectionClass, + classDefinedDataIterableAnnotations: (new DataIterableAnnotationReader)->getForProperty( + $reflectionProperty, + ), + ); + } + + /** + * Create the creation context for one cast. + */ + protected function context(): CreationContext + { + return new CreationContext(SimpleData::class); + } +} + +class UnserializeCastDataFixture +{ + public DummyBackedEnum $enum; + + public SimpleData $data; +} + +class UnserializeCastAllowedData extends Data +{ + /** + * Create a data object that restores its serialized data object. + */ + public function __construct( + #[WithCast(UnserializeCast::class, allowedClasses: [SimpleData::class])] + public SimpleData $data, + ) { + } +} + +class UnserializeCastRestrictedData extends Data +{ + /** + * Create a data object that restores no serialized objects. + */ + public function __construct( + #[WithCast(UnserializeCast::class)] + public SimpleData $data, + ) { + } +} + +class UnserializeCastCanceledHook +{ + /** + * Create a value whose restore hook is canceled. + */ + public function __construct( + public bool $cleanupFails = false, + ) { + } + + /** + * Fail as a restore hook does when its coroutine is canceled while it waits. + */ + public function __unserialize(array $data): void + { + try { + throw new CanceledException('Canceled while restoring.'); + } finally { + if ($data['cleanupFails']) { + throw new LogicException('Cleanup failed.'); + } + } + } +} diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 17b8cc4723..dd87a15219 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -83,7 +83,6 @@ class DataCreatorTest extends TestCase // REMOVED: Configurable pipeline tests; use the fixed engine and factory hooks. // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. - // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. protected function getPackageProviders(Application $app): array { diff --git a/tests/Inertia/BroadcastPropsTest.php b/tests/Inertia/BroadcastPropsTest.php new file mode 100644 index 0000000000..09df489308 --- /dev/null +++ b/tests/Inertia/BroadcastPropsTest.php @@ -0,0 +1,367 @@ +order = $resource; + } + + /** + * Transform the resource into an array. + * + * @return array{id: int, total: int} + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->order['id'], + 'total' => $this->order['total'], + ]; + } +} + +/** + * @implements Arrayable + */ +class BroadcastOrderSummary implements Arrayable +{ + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return [ + 'id' => 1, + 'total' => 125, + ]; + } +} + +class BroadcastOrderUpdated +{ + /** + * Get the data to broadcast. + * + * @return array{id: int, event: string, __inertia: array{props: array{status: string}}} + */ + public function broadcastWith(): array + { + return [ + 'id' => 1, + 'event' => 'order.updated', + ...Inertia::broadcastProps([ + 'status' => 'paid', + ]), + ]; + } +} + +class BroadcastOrderProperties implements ProvidesInertiaProperties +{ + /** + * Get the Inertia properties. + * + * @return array{id: int, status: string} + */ + public function toInertiaProperties(RenderContext $context): array + { + return [ + 'id' => 1, + 'status' => 'paid', + ]; + } +} + +class BroadcastPropsTest extends TestCase +{ + public function testReturnsTheInertiaPropsEnvelopeAndNothingElse(): void + { + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + ]); + + $this->assertSame(['__inertia'], array_keys($payload)); + $this->assertSame(['props'], array_keys($payload['__inertia'])); + $this->assertSame(['order' => ['id' => 1]], $payload['__inertia']['props']); + } + + public function testAJsonResourceValueResolvesToTheSameArrayAsAPageProp(): void + { + $order = ['id' => 1, 'total' => 125]; + + $page = $this->renderPage([ + 'order' => BroadcastOrderResource::make($order), + ]); + + $payload = $this->broadcastProps([ + 'order' => BroadcastOrderResource::make($order), + ]); + + $this->assertSame($page['props']['order'], $payload['__inertia']['props']['order']); + } + + public function testAClosureValueResolves(): void + { + $payload = $this->broadcastProps([ + 'path' => fn (Request $request) => $request->path(), + ], Request::create('/orders/1', 'GET')); + + $this->assertSame('orders/1', $payload['__inertia']['props']['path']); + } + + public function testAnArrayableValueResolves(): void + { + $payload = $this->broadcastProps([ + 'order' => new BroadcastOrderSummary, + ]); + + $this->assertSame(['id' => 1, 'total' => 125], $payload['__inertia']['props']['order']); + } + + public function testEveryPropNamedInThePayloadIsResolved(): void + { + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + 'deferred' => Inertia::defer(fn () => 'deferred-value'), + 'optional' => Inertia::optional(fn () => 'optional-value'), + ]); + + $this->assertSame([ + 'order' => ['id' => 1], + 'deferred' => 'deferred-value', + 'optional' => 'optional-value', + ], $payload['__inertia']['props']); + } + + public function testAReusedScrollPropResolvesAgainOnEachBroadcast(): void + { + $callCount = 0; + $feed = Inertia::scroll(function () use (&$callCount) { + return ['data' => [['id' => ++$callCount]]]; + }); + + $first = $this->broadcastProps(['feed' => $feed]); + $second = $this->broadcastProps(['feed' => $feed]); + + $this->assertSame([['id' => 1]], $first['__inertia']['props']['feed']['data']); + $this->assertSame([['id' => 2]], $second['__inertia']['props']['feed']['data']); + } + + public function testARescuedPropIsLeftOutOfThePayload(): void + { + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + 'stats' => Inertia::defer(function () { + throw new RuntimeException('Rescue this broadcast prop'); + }, rescue: true), + ]); + + $this->assertSame(['order' => ['id' => 1]], $payload['__inertia']['props']); + $this->assertArrayNotHasKey('stats', $payload['__inertia']['props']); + } + + public function testARescuedPropReturnedFromAClosureIsLeftOutOfThePayload(): void + { + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + 'stats' => fn () => Inertia::defer(function () { + throw new RuntimeException('Rescue this broadcast prop'); + }, rescue: true), + ]); + + $this->assertSame(['order' => ['id' => 1]], $payload['__inertia']['props']); + } + + public function testAOncePropTheRequestingClientAlreadyHasIsStillBroadcast(): void + { + $request = Request::create('/orders/1', 'GET'); + $request->headers->add([ + Header::INERTIA => 'true', + Header::EXCEPT_ONCE_PROPS => 'settings', + ]); + + $payload = $this->broadcastProps([ + 'settings' => Inertia::once(fn () => ['theme' => 'dark']), + ], $request); + + $this->assertSame(['theme' => 'dark'], $payload['__inertia']['props']['settings']); + } + + public function testPartialReloadHeadersDoNotFilterThePayload(): void + { + $request = Request::create('/orders/1', 'GET'); + $request->headers->add([ + Header::INERTIA => 'true', + Header::PARTIAL_COMPONENT => 'Orders/Show', + Header::PARTIAL_EXCEPT => 'order', + ]); + + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + ], $request); + + $this->assertSame(['id' => 1], $payload['__inertia']['props']['order']); + } + + public function testBroadcastingDuringAPageRenderDoesNotRecordThePropsOnTheDevtoolsEntry(): void + { + config()->set('inertia.devtools.enabled', true); + config()->set('inertia.devtools.except', []); + + $request = Request::create('/orders/1', 'GET'); + $request->headers->add([Header::INERTIA => 'true']); + $this->bindRequest($request); + + $response = Inertia::render('Orders/Show', ['order' => ['id' => 1]]); + + Inertia::broadcastProps(['broadcastOnlyStatus' => 'paid']); + + $response->toResponse($request); + + $payload = $request->attributes->get(RequestAttribute::PAYLOAD); + + $this->assertIsArray($payload); + $this->assertArrayHasKey('order', $payload['props']); + $this->assertArrayNotHasKey('broadcastOnlyStatus', $payload['props']); + } + + public function testAnAlwaysBroadcastPropStillResolves(): void + { + $payload = $this->broadcastProps([ + 'always' => Inertia::always('always-value'), + ]); + + $this->assertSame('always-value', $payload['__inertia']['props']['always']); + } + + public function testDotPathKeysStayFlatAndLiteral(): void + { + $payload = $this->broadcastProps([ + 'order.total' => 125, + ]); + + $this->assertSame(['order.total' => 125], $payload['__inertia']['props']); + $this->assertArrayNotHasKey('order', $payload['__inertia']['props']); + } + + public function testTheAuthorsOwnSiblingKeysInBroadcastWithAreUntouched(): void + { + $this->bindRequest(Request::create('/orders/1', 'GET')); + + $payload = (new BroadcastOrderUpdated)->broadcastWith(); + + $this->assertSame(['id', 'event', '__inertia'], array_keys($payload)); + $this->assertSame(1, $payload['id']); + $this->assertSame('order.updated', $payload['event']); + $this->assertSame(['status' => 'paid'], $payload['__inertia']['props']); + } + + public function testMultiplePropsResolveInOneCall(): void + { + $payload = $this->broadcastProps([ + 'order' => ['id' => 1], + 'status' => 'paid', + ]); + + $this->assertSame([ + 'order' => ['id' => 1], + 'status' => 'paid', + ], $payload['__inertia']['props']); + } + + public function testANumericallyKeyedPropertyProviderContributesItsOwnKeys(): void + { + $payload = $this->broadcastProps([ + new BroadcastOrderProperties, + ]); + + $this->assertSame(['id' => 1, 'status' => 'paid'], $payload['__inertia']['props']); + } + + public function testConstructingTheBroadcastResolverDoesNotRecordADevtoolsEntry(): void + { + config()->set('inertia.devtools.enabled', true); + config()->set('inertia.devtools.except', []); + + $request = Request::create('/orders/1', 'GET'); + + $this->broadcastProps([ + 'order' => fn () => ['id' => 1], + ], $request); + + $this->assertNull($this->app->make(EntryStore::class)->current()); + $this->assertFalse($request->attributes->has(RequestAttribute::PAYLOAD)); + } + + /** + * Resolve the given broadcast props for the given request. + * + * @param array $props + * @return array{__inertia: array{props: array}} + */ + protected function broadcastProps(array $props, ?Request $request = null): array + { + $this->bindRequest($request ?? Request::create('/broadcast', 'GET')); + + return Inertia::broadcastProps($props); + } + + /** + * Make the given request the current request. + */ + protected function bindRequest(Request $request): void + { + RequestContext::set($request); + } + + /** + * Render the given props as an Inertia page. + * + * @param array $props + * @return array + */ + protected function renderPage(array $props): array + { + $request = Request::create('/orders/1', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = (new Response('Orders/Show', [], $props, 'app', '123'))->toResponse($request); + + $this->assertInstanceOf(JsonResponse::class, $response); + + return $response->getData(true); + } +} diff --git a/tests/Inertia/DevTools/CollectorIntegrationTest.php b/tests/Inertia/DevTools/CollectorIntegrationTest.php index 6bbec7c782..91a5058d2c 100644 --- a/tests/Inertia/DevTools/CollectorIntegrationTest.php +++ b/tests/Inertia/DevTools/CollectorIntegrationTest.php @@ -247,7 +247,7 @@ public function testDeferredPropReloadedOutsideADeferredRequestReadsAsRegular(): public function testRescuedDeferredPropIsFlagged(): void { Route::middleware(Middleware::class)->get('/rescue-route', fn (): Response => Inertia::render('Users/Index', [ - 'flaky' => Inertia::defer(fn (): never => throw new RuntimeException('boom'), rescue: true), + 'flaky' => Inertia::defer(fn (): never => throw new RuntimeException('boom'), rescue: true)->live(on: 'order.updated', channel: 'orders.1'), ])); $this->get('/rescue-route', [ @@ -264,9 +264,57 @@ public function testRescuedDeferredPropIsFlagged(): void $this->assertSame('defer', $entry['props']['flaky']['inertiaType']); $this->assertTrue($entry['props']['flaky']['rescued']); + // The prop is still live, whatever its resolver did + $this->assertTrue($entry['props']['flaky']['live']); $this->assertArrayNotHasKey('flaky', $entry['propValues'] ?? []); } + public function testOncePropsAreFlagged(): void + { + Route::middleware(Middleware::class)->get('/once-route', fn (): Response => Inertia::render('Users/Index', [ + 'config' => Inertia::once(fn (): string => 'cached'), + 'plain' => 'nothing', + ])); + + $this->get('/once-route', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '', + ]); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $props = $this->latestRecordedEntry()['props']; + + $this->assertTrue($props['config']['once']); + $this->assertArrayNotHasKey('once', $props['plain']); + } + + public function testLivePropsAreFlaggedWithoutLosingTheirWrapperType(): void + { + Route::middleware(Middleware::class)->get('/live-route', fn (): Response => Inertia::render('Users/Index', [ + 'order' => Inertia::live('shipped', 'order.updated', 'orders.1'), + 'stats' => Inertia::always(fn (): string => 'up')->live('order.updated', 'orders.1'), + 'plain' => 'nothing', + ])); + + $this->get('/live-route', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '', + ]); + + $this->app->make(EntryStore::class)->flush($this->repo); + + $props = $this->latestRecordedEntry()['props']; + + $this->assertNull($props['order']['inertiaType']); + $this->assertTrue($props['order']['live']); + + $this->assertSame('always', $props['stats']['inertiaType']); + $this->assertTrue($props['stats']['live']); + + $this->assertArrayNotHasKey('live', $props['plain']); + } + public function testShareSourcesPopulateWhenShareIsCalled(): void { Inertia::share('flash', 'hello'); diff --git a/tests/Inertia/DevTools/EntriesRepositoryTest.php b/tests/Inertia/DevTools/EntriesRepositoryTest.php index 084b6b95ca..0cf82fc655 100644 --- a/tests/Inertia/DevTools/EntriesRepositoryTest.php +++ b/tests/Inertia/DevTools/EntriesRepositoryTest.php @@ -11,7 +11,9 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Inertia\TestCase; use InvalidArgumentException; +use Mockery as m; use RuntimeException; +use Swoole\Coroutine\CanceledException; class EntriesRepositoryTest extends TestCase { @@ -99,6 +101,28 @@ public function testGetReturnsNullForMissingEntry(): void $this->assertNull($repo->get('does-not-exist')); } + public function testGetRethrowsCancellationWhileReadingTheEntry(): void + { + $payload = $this->envelope(); + $id = $payload['__meta']['id']; + + $this->makeRepository()->save($id, $payload); + + $cancellation = new CanceledException('canceled'); + $files = m::mock(Filesystem::class)->makePartial(); + $files->shouldReceive('get')->andThrow($cancellation); + + $repo = new EntriesRepository(path: $this->storagePath, files: $files); + + try { + $repo->get($id); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + } + public function testSaveRejectsInvalidEntryIds(): void { $repo = $this->makeRepository(); diff --git a/tests/Inertia/DevTools/EntryStoreTest.php b/tests/Inertia/DevTools/EntryStoreTest.php index a0a92979ac..510273a24c 100644 --- a/tests/Inertia/DevTools/EntryStoreTest.php +++ b/tests/Inertia/DevTools/EntryStoreTest.php @@ -10,6 +10,8 @@ use Hypervel\Support\Facades\Log; use Hypervel\Tests\Inertia\TestCase; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class EntryStoreTest extends TestCase { @@ -121,6 +123,57 @@ public function testCircuitBreakerSuppressesWhenLoggingTheFailureAlsoFails(): vo $this->assertSame(1, $failing->calls, 'Second flush should be suppressed by circuit breaker.'); } + + public function testCancellationIsRethrownWithoutTrippingTheCircuitBreaker(): void + { + Log::shouldReceive('warning')->never(); + + $cancellation = new CanceledException('canceled'); + $recorder = new EntryStore; + + $recorder->record(new IncomingEntry); + + try { + $recorder->flush(new FailingEntriesRepository($cancellation)); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $repo = new RecorderSpyRepo; + + $recorder->record(new IncomingEntry); + $recorder->flush($repo); + + $this->assertCount(1, $repo->saved, 'A canceled flush must not suppress later recording.'); + } + + public function testCancellationWhileLoggingTheFailureIsRethrown(): void + { + $cancellation = new CanceledException('canceled'); + + Log::shouldReceive('warning')->once()->andThrow($cancellation); + + $recorder = new EntryStore; + + $recorder->record(new IncomingEntry); + + try { + $recorder->flush(new FailingEntriesRepository); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $repo = new RecorderSpyRepo; + + $recorder->record(new IncomingEntry); + $recorder->flush($repo); + + $this->assertCount(0, $repo->saved, 'The storage failure must still suspend recording when logging it is canceled.'); + } } /** @@ -178,8 +231,9 @@ class FailingEntriesRepository extends EntriesRepository /** * Create a new failing repository instance without a storage directory. */ - public function __construct() - { + public function __construct( + protected Throwable $failure = new RuntimeException('disk full'), + ) { } /** @@ -191,6 +245,6 @@ public function save(string $id, array $data): void { ++$this->calls; - throw new RuntimeException('disk full'); + throw $this->failure; } } diff --git a/tests/Inertia/DevTools/PropClassifierTest.php b/tests/Inertia/DevTools/PropClassifierTest.php index 5c6ff84a05..5abf61275c 100644 --- a/tests/Inertia/DevTools/PropClassifierTest.php +++ b/tests/Inertia/DevTools/PropClassifierTest.php @@ -23,7 +23,7 @@ class PropClassifierTest extends TestCase * Classify the prop for a request with the given headers. * * @param array $headers - * @return array{inertiaType: ?PropType, deferGroup: ?string, reset: bool, once: bool, mergeDirection: ?string, deepMerge: bool} + * @return array{inertiaType: ?PropType, deferGroup: ?string, reset: bool, once: bool, live: bool, mergeDirection: ?string, deepMerge: bool} */ private function classify(string $path, mixed $prop, array $headers = []): array { @@ -89,6 +89,35 @@ public function testPlainValueHasNoInertiaType(): void $this->assertNull($result['mergeDirection']); $this->assertFalse($result['deepMerge']); $this->assertFalse($result['once']); + $this->assertFalse($result['live']); + } + + public function testLivePropKeepsItsInertiaTypeAndFlagsLive(): void + { + $result = $this->classify('name', Inertia::live('Alice', on: 'order.updated')); + + $this->assertNull($result['inertiaType']); + $this->assertTrue($result['live']); + } + + public function testComposedLivePropKeepsItsInertiaTypeAndFlagsLive(): void + { + $result = $this->classify('stats', Inertia::defer(fn (): string => 'value')->live(on: 'order.updated'), [ + DevToolsHeader::DEVTOOLS_DEFERRED => '1', + ]); + + $this->assertSame(PropType::Defer, $result['inertiaType']); + $this->assertTrue($result['live']); + } + + public function testNonLivePropReportsLiveFalse(): void + { + $result = $this->classify('stats', Inertia::defer(fn (): string => 'value'), [ + DevToolsHeader::DEVTOOLS_DEFERRED => '1', + ]); + + $this->assertSame(PropType::Defer, $result['inertiaType']); + $this->assertFalse($result['live']); } public function testDeferPropOutsideADeferredRequestReadsAsRegular(): void @@ -238,8 +267,11 @@ public function testMatchOnImpliesDeepMerge(): void public function testMatchOnWithoutMergingIsNotADeepMerge(): void { - $result = $this->classify('items', Inertia::defer(fn (): array => [['id' => 1]])->matchOn('id')); + // `matchOn` alone leaves merging off, so the page object records nothing + // for it either. Reporting a deep merge here would contradict the wire. + $result = $this->classify('items', Inertia::defer(fn (): array => [['id' => 1]])->live(on: 'order.updated', channel: 'orders.1')->matchOn('id')); + $this->assertTrue($result['live']); $this->assertFalse($result['deepMerge']); $this->assertNull($result['mergeDirection']); } diff --git a/tests/Inertia/LivePropTest.php b/tests/Inertia/LivePropTest.php new file mode 100644 index 0000000000..8da3e3403c --- /dev/null +++ b/tests/Inertia/LivePropTest.php @@ -0,0 +1,1446 @@ +id); + } +} + +class UserUpdated +{ + /** + * Create a new event instance. + */ + public function __construct(public int $id = 7) + { + } + + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('users.' . $this->id); + } +} + +class OrderPaid +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders.1'); + } +} + +class OrderShipped +{ + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [new Channel('stats'), new PresenceChannel('room.1')]; + } +} + +class OrderCreated +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): Channel + { + return new Channel('orders'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): string + { + return 'order.created'; + } +} + +class OrderArchived +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): string + { + return 'order.archived'; + } +} + +class OrderRestored +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders'); + } +} + +class OrderRenamed +{ + /** + * Create a new event instance. + */ + public function __construct(public string $name = 'order') + { + } + + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): string + { + return "{$this->name}.renamed"; + } +} + +enum OrderEvent: string +{ + case Refunded = 'order.refunded'; +} + +class OrderRefunded +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders.1'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): OrderEvent + { + return OrderEvent::Refunded; + } +} + +enum OrderEventCode: int +{ + case Escalated = 42; +} + +class OrderEscalated +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders.1'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): OrderEventCode + { + return OrderEventCode::Escalated; + } +} + +class OrderNoted +{ + /** + * @var null|object{id: int} + */ + public ?object $order = null; + + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders.' . $this->order->id); + } +} + +class OrderBroadcastPerConnection +{ + /** + * Get the channels the event should broadcast on, keyed by connection. + * + * @return array> + */ + public function broadcastOn(): array + { + return [ + 'reverb' => [new PrivateChannel('orders.1')], + 'pusher' => [new PrivateChannel('pusher.orders.1')], + ]; + } + + /** + * Get the broadcaster connections the event should be broadcast on. + * + * The broadcast job only sends a connection its named channels when the event is + * broadcast via that connection, which upstream's fixture leaves out. + * + * @return array + */ + public function broadcastConnections(): array + { + return ['reverb', 'pusher']; + } +} + +class OrderBroadcastOnUnnamedOrDefaultConnection +{ + use InteractsWithBroadcasting; + + /** + * Get the channels the event should broadcast on, keyed by connection. + * + * @return array> + */ + public function broadcastOn(): array + { + return [ + '' => [new PrivateChannel('orders.1')], + 'reverb' => [new PrivateChannel('reverb.orders.1')], + 'pusher' => [new PrivateChannel('pusher.orders.1')], + ]; + } +} + +class OrderSecured +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): EncryptedPrivateChannel + { + return new EncryptedPrivateChannel('orders.1'); + } +} + +class OrderWithoutChannels +{ +} + +class OrderWithEmptyChannels +{ + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return []; + } +} + +class OrderBroadcastOnString +{ + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return ['orders']; + } +} + +class OrderBroadcastOnMixed +{ + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return ['orders', new PrivateChannel('secret')]; + } +} + +class BroadcastableOrder implements HasBroadcastChannel +{ + /** + * Get the broadcast channel route definition. + */ + public function broadcastChannelRoute(): string + { + return 'orders.{order}'; + } + + /** + * Get the broadcast channel name. + */ + public function broadcastChannel(): string + { + return 'orders.9'; + } +} + +class OrderBroadcastOnModel +{ + /** + * Get the entity the event should broadcast on. + */ + public function broadcastOn(): BroadcastableOrder + { + return new BroadcastableOrder; + } +} + +class OrderBroadcastOnEveryType +{ + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + 'stats', + new PrivateChannel('orders.1'), + new PresenceChannel('room.1'), + new EncryptedPrivateChannel('secret.1'), + ]; + } +} + +class OrderNamedDuringCancellation +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel('orders'); + } + + /** + * Get the event's broadcast name. + */ + public function broadcastAs(): string + { + throw LivePropTest::$cancellation; + } +} + +class OrderRoutedDuringCancellation +{ + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + throw LivePropTest::$cancellation; + } +} + +class OrderRoutedOnCount +{ + /** + * The number of times the channels were read. + */ + public int $reads = 0; + + /** + * Get the channel the event should broadcast on. + */ + public function broadcastOn(): PrivateChannel + { + ++$this->reads; + + return new PrivateChannel('orders'); + } +} + +class LivePropTest extends TestCase +{ + /** + * The cancellation the cancellation fixtures throw. + */ + public static ?Throwable $cancellation = null; + + public function testCanInvokeWithAValue(): void + { + $liveProp = new LiveProp('A live prop value', new OrderUpdated); + + $this->assertSame('A live prop value', $liveProp()); + } + + public function testCanInvokeWithACallback(): void + { + $liveProp = new LiveProp(fn () => 'A live prop value', new OrderUpdated); + + $this->assertSame('A live prop value', $liveProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $liveProp = new LiveProp(fn (Request $request) => $request, new OrderUpdated); + + $this->assertInstanceOf(Request::class, $liveProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $liveProp = new LiveProp('date', new OrderUpdated); + + $this->assertSame('date', $liveProp()); + } + + public function testTheFactoryCreatesALiveProp(): void + { + $liveProp = Inertia::live('bar', on: new OrderUpdated); + + $this->assertInstanceOf(LiveProp::class, $liveProp); + $this->assertTrue($liveProp->isLive()); + } + + public function testPropsAreNotLiveByDefault(): void + { + $this->assertFalse((new AlwaysProp('bar'))->isLive()); + $this->assertFalse((new OptionalProp(fn () => 'bar'))->isLive()); + $this->assertFalse((new OnceProp(fn () => 'bar'))->isLive()); + $this->assertFalse((new DeferProp(fn () => 'bar'))->isLive()); + $this->assertFalse(is_a(MergeProp::class, SupportsLiveUpdates::class, true)); + $this->assertFalse(is_a(ScrollProp::class, SupportsLiveUpdates::class, true)); + } + + public function testSupportedPropTypesCanBeMarkedAsLive(): void + { + $props = [ + new AlwaysProp('bar'), + new OptionalProp(fn () => 'bar'), + new OnceProp(fn () => 'bar'), + new DeferProp(fn () => 'bar'), + ]; + + foreach ($props as $prop) { + $result = $prop->live(on: new OrderUpdated); + + $this->assertSame($prop, $result); + $this->assertTrue($prop->isLive()); + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + ], $prop->liveListeners()); + } + } + + public function testAMergePropCannotBeMadeLive(): void + { + $prop = (new DeferProp(fn () => 'bar'))->merge(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Live updates are not supported on merge properties.'); + + $prop->live(on: new OrderUpdated); + } + + public function testALivePropCannotBeMadeMergeable(): void + { + // Guarded from both sides, since the order the two are called in is + // the author's to choose + $prop = (new DeferProp(fn () => 'bar'))->live(on: new OrderUpdated); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Live updates are not supported on merge properties.'); + + $prop->merge(); + } + + public function testADeepMergePropCannotBeMadeLive(): void + { + $prop = (new DeferProp(fn () => 'bar'))->deepMerge(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Live updates are not supported on merge properties.'); + + $prop->live(on: new OrderUpdated); + } + + public function testALivePropMayStillConfigureHowItWouldMerge(): void + { + // Only [merge] declares the intent, so appending at a path leaves a + // live prop alone until it is actually marked for merging + $prop = (new DeferProp(fn () => 'bar'))->live(on: new OrderUpdated)->append('items'); + + $this->assertTrue($prop->isLive()); + $this->assertFalse($prop->shouldMerge()); + } + + public function testTheEventClassNameIsUsedAsTheEventName(): void + { + $liveProp = new LiveProp('bar', new OrderUpdated); + + $this->assertSame([OrderUpdated::class], $liveProp->liveListeners()[0]['events']); + } + + public function testTheBroadcastAsNameIsUsedAsTheEventName(): void + { + $liveProp = new LiveProp('bar', new OrderCreated); + + $this->assertSame(['order.created'], $liveProp->liveListeners()[0]['events']); + } + + public function testCanListenForMultipleEvents(): void + { + $liveProp = new LiveProp('bar', [new OrderUpdated, new OrderShipped]); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderShipped::class], + ], + [ + 'channel' => ['name' => 'room.1', 'type' => 'presence'], + 'events' => [OrderShipped::class], + ], + ], $liveProp->liveListeners()); + } + + public function testEventsOnDifferentChannelsKeepTheirOwnListenerPairs(): void + { + $liveProp = new LiveProp('bar', [new OrderUpdated, new UserUpdated]); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + [ + 'channel' => ['name' => 'users.7', 'type' => 'private'], + 'events' => [UserUpdated::class], + ], + ], $liveProp->liveListeners()); + } + + public function testEventsOnTheSameChannelCollapseIntoOneListener(): void + { + $liveProp = new LiveProp('bar', [new OrderUpdated, new OrderPaid]); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class, OrderPaid::class], + ], + ], $liveProp->liveListeners()); + } + + public function testOneEventBroadcastingOnSeveralChannelsGetsOneListenerPerChannel(): void + { + $liveProp = new LiveProp('bar', new OrderShipped); + + $this->assertSame([ + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderShipped::class], + ], + [ + 'channel' => ['name' => 'room.1', 'type' => 'presence'], + 'events' => [OrderShipped::class], + ], + ], $liveProp->liveListeners()); + } + + public function testExplicitChannelsPairEveryEventWithEveryChannel(): void + { + $liveProp = new LiveProp('bar', [new OrderUpdated, new UserUpdated], ['orders.1', new Channel('stats')]); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'public'], + 'events' => [OrderUpdated::class, UserUpdated::class], + ], + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderUpdated::class, UserUpdated::class], + ], + ], $liveProp->liveListeners()); + } + + public function testDuplicateEventsAndChannelsAreRemoved(): void + { + $liveProp = new LiveProp('bar', [new OrderUpdated(1), new OrderUpdated(1)]); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + ], $liveProp->liveListeners()); + } + + public function testTheChannelTypeIsResolvedFromTheChannelClass(): void + { + $this->assertSame( + [['channel' => ['name' => 'stats', 'type' => 'public'], 'events' => [OrderUpdated::class]]], + (new LiveProp('bar', new OrderUpdated, new Channel('stats')))->liveListeners() + ); + + $this->assertSame( + [['channel' => ['name' => 'orders.1', 'type' => 'private'], 'events' => [OrderUpdated::class]]], + (new LiveProp('bar', new OrderUpdated, new PrivateChannel('orders.1')))->liveListeners() + ); + + $this->assertSame( + [['channel' => ['name' => 'room.1', 'type' => 'presence'], 'events' => [OrderUpdated::class]]], + (new LiveProp('bar', new OrderUpdated, new PresenceChannel('room.1')))->liveListeners() + ); + + $this->assertSame( + [['channel' => ['name' => 'orders.1', 'type' => 'encrypted-private'], 'events' => [OrderUpdated::class]]], + (new LiveProp('bar', new OrderUpdated, new EncryptedPrivateChannel('orders.1')))->liveListeners() + ); + } + + public function testTheChannelPrefixIsNeverBroadcast(): void + { + $liveProp = new LiveProp('bar', new OrderSecured); + + $this->assertSame(['name' => 'orders.1', 'type' => 'encrypted-private'], $liveProp->liveListeners()[0]['channel']); + } + + public function testAPublicChannelKeepsANameThatLooksPrefixed(): void + { + $liveProp = new LiveProp('bar', new OrderUpdated, new Channel('private-orders.1')); + + $this->assertSame([['channel' => ['name' => 'private-orders.1', 'type' => 'public'], 'events' => [OrderUpdated::class]]], $liveProp->liveListeners()); + } + + public function testABareStringChannelIsPublicWhereverItIsGiven(): void + { + // Laravel casts a channel to a string without adding a prefix, so a bare + // string means the same thing here as it does in [broadcastOn] + $given = new LiveProp('bar', new OrderUpdated, 'orders'); + $broadcast = new LiveProp('bar', new OrderBroadcastOnString); + + $this->assertSame(['name' => 'orders', 'type' => 'public'], $given->liveListeners()[0]['channel']); + $this->assertSame(['name' => 'orders', 'type' => 'public'], $broadcast->liveListeners()[0]['channel']); + } + + public function testCanSubscribeToMultipleChannels(): void + { + $liveProp = new LiveProp('bar', new OrderUpdated, ['orders.1', new Channel('stats')]); + + $this->assertSame([ + ['channel' => ['name' => 'orders.1', 'type' => 'public'], 'events' => [OrderUpdated::class]], + ['channel' => ['name' => 'stats', 'type' => 'public'], 'events' => [OrderUpdated::class]], + ], $liveProp->liveListeners()); + } + + public function testTheGivenChannelWinsOverTheChannelsTheEventBroadcastsOn(): void + { + $liveProp = new LiveProp('bar', new OrderUpdated, 'other-orders.1'); + + $this->assertSame([['channel' => ['name' => 'other-orders.1', 'type' => 'public'], 'events' => [OrderUpdated::class]]], $liveProp->liveListeners()); + } + + public function testCanListenForAnEventClassName(): void + { + $liveProp = new LiveProp('bar', OrderRestored::class, 'orders.1'); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'public'], + 'events' => [OrderRestored::class], + ], + ], $liveProp->liveListeners()); + } + + public function testAnEventClassNameResolvesItsOwnBroadcastName(): void + { + $liveProp = new LiveProp('bar', OrderArchived::class, 'orders.1'); + + $this->assertSame(['order.archived'], $liveProp->liveListeners()[0]['events']); + } + + public function testAnEventClassNameWhoseBroadcastNameComesFromThePayloadThrows(): void + { + // The payload is what a class name cannot supply, so the name would + // otherwise resolve against an uninitialised property + $liveProp = new LiveProp('bar', OrderRenamed::class, 'orders.1'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unable to resolve the broadcast name for the [' . OrderRenamed::class . '] event because it builds it from its payload.'); + + $liveProp->liveListeners(); + } + + public function testAnEnumBroadcastNameResolvesToItsValue(): void + { + $this->assertSame(['order.refunded'], (new LiveProp('bar', new OrderRefunded))->liveListeners()[0]['events']); + $this->assertSame(['order.refunded'], (new LiveProp('bar', OrderRefunded::class))->liveListeners()[0]['events']); + } + + public function testAnIntegerBackedEnumBroadcastNameResolvesToItsValueAsAString(): void + { + $this->assertSame(['42'], (new LiveProp('bar', new OrderEscalated))->liveListeners()[0]['events']); + $this->assertSame(['42'], (new LiveProp('bar', OrderEscalated::class))->liveListeners()[0]['events']); + } + + public function testAnEventClassNameThatDoesNotExistThrows(): void + { + $liveProp = new LiveProp('bar', 'App\Events\OrderUpdated', 'orders.1'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The [App\Events\OrderUpdated] event class does not exist.'); + + $liveProp->liveListeners(); + } + + public function testAnEventNameWithoutANamespaceIsLeftUntouched(): void + { + $liveProp = new LiveProp('bar', ['order.updated', 'OrderUpdated'], 'orders.1'); + + $this->assertSame(['order.updated', 'OrderUpdated'], $liveProp->liveListeners()[0]['events']); + } + + public function testAnEventClassNameResolvesItsOwnChannels(): void + { + $liveProp = new LiveProp('bar', OrderRestored::class); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders', 'type' => 'private'], + 'events' => [OrderRestored::class], + ], + ], $liveProp->liveListeners()); + } + + public function testAnEventClassNameThatBuildsItsChannelsFromThePayloadThrows(): void + { + // The payload is what a class name cannot supply, so [OrderUpdated] would + // otherwise resolve to `orders.` and listen on a channel nobody broadcasts on + $liveProp = new LiveProp('bar', OrderUpdated::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unable to resolve the channels for the [' . OrderUpdated::class . '] event because it builds them from its payload.'); + + $liveProp->liveListeners(); + } + + public function testAnEventClassNameWithAnUntypedPayloadThrows(): void + { + // A null property read raises a warning rather than an Error, which + // Hypervel turns into an ErrorException + $liveProp = new LiveProp('bar', OrderNoted::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unable to resolve the channels for the [' . OrderNoted::class . '] event because it builds them from its payload.'); + + $liveProp->liveListeners(); + } + + public function testCancellationWhileResolvingAnEventClassBroadcastNameIsRethrown(): void + { + static::$cancellation = new CanceledException('canceled'); + + try { + (new LiveProp('bar', OrderNamedDuringCancellation::class, 'orders.1'))->liveListeners(); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame(static::$cancellation, $exception); + } + } + + public function testCancellationWhileResolvingEventClassChannelsIsRethrown(): void + { + static::$cancellation = new CanceledException('canceled'); + + try { + (new LiveProp('bar', OrderRoutedDuringCancellation::class))->liveListeners(); + + $this->fail('The cancellation was not rethrown.'); + } catch (CanceledException $exception) { + $this->assertSame(static::$cancellation, $exception); + } + } + + public function testChannelsKeyedByConnectionAreReadForTheDefaultConnection(): void + { + config(['broadcasting.default' => 'reverb']); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderBroadcastPerConnection::class], + ], + ], (new LiveProp('bar', new OrderBroadcastPerConnection))->liveListeners()); + } + + public function testChannelsKeyedByConnectionFollowTheConnectionTheEventIsBroadcastVia(): void + { + config(['broadcasting.default' => 'reverb']); + + // Without a named connection, the broadcast job sends the '' channels on the default connection + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderBroadcastOnUnnamedOrDefaultConnection::class], + ], + ], (new LiveProp('bar', new OrderBroadcastOnUnnamedOrDefaultConnection))->liveListeners()); + + $this->assertSame([ + [ + 'channel' => ['name' => 'reverb.orders.1', 'type' => 'private'], + 'events' => [OrderBroadcastOnUnnamedOrDefaultConnection::class], + ], + ], (new LiveProp('bar', (new OrderBroadcastOnUnnamedOrDefaultConnection)->broadcastVia('reverb')))->liveListeners()); + + // Both groups go out on the default connection when the event is broadcast via both + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderBroadcastOnUnnamedOrDefaultConnection::class], + ], + [ + 'channel' => ['name' => 'reverb.orders.1', 'type' => 'private'], + 'events' => [OrderBroadcastOnUnnamedOrDefaultConnection::class], + ], + ], (new LiveProp('bar', (new OrderBroadcastOnUnnamedOrDefaultConnection)->broadcastVia([null, 'reverb'])))->liveListeners()); + } + + public function testChannelsKeyedByConnectionForAnEventNotBroadcastOnTheDefaultConnectionThrow(): void + { + config(['broadcasting.default' => 'reverb']); + + $liveProp = new LiveProp('bar', (new OrderBroadcastOnUnnamedOrDefaultConnection)->broadcastVia('pusher')); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The [' . OrderBroadcastOnUnnamedOrDefaultConnection::class . '] event keys its channels by connection, but has none for the default [reverb] connection.'); + + $liveProp->liveListeners(); + } + + public function testChannelsKeyedByConnectionWithoutTheDefaultConnectionThrow(): void + { + config(['broadcasting.default' => 'ably']); + + $liveProp = new LiveProp('bar', new OrderBroadcastPerConnection); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The [' . OrderBroadcastPerConnection::class . '] event keys its channels by connection, but has none for the default [ably] connection.'); + + $liveProp->liveListeners(); + } + + public function testABroadcastNameWithoutAChannelThrows(): void + { + $liveProp = new LiveProp('bar', 'order.updated'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unable to resolve the channels for the [order.updated] event because it was given as a string.'); + + $liveProp->liveListeners(); + } + + public function testAnEventWithoutChannelsThrows(): void + { + $liveProp = new LiveProp('bar', new OrderWithoutChannels); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The [' . OrderWithoutChannels::class . '] event does not define a [broadcastOn] method.'); + + $liveProp->liveListeners(); + } + + public function testAnEventThatBroadcastsOnNoChannelsThrows(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The [' . OrderWithEmptyChannels::class . '] event does not broadcast on any channels.'); + + (new LiveProp('bar', new OrderWithEmptyChannels))->liveListeners(); + } + + public function testALivePropWithoutEventsThrows(): void + { + $liveProp = new LiveProp('bar'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A live property must listen for at least one event.'); + + $liveProp->liveListeners(); + } + + public function testTheThrottleIsNullByDefault(): void + { + $liveProp = new LiveProp('bar', new OrderUpdated); + + $this->assertNull($liveProp->liveThrottle()); + } + + public function testCanSetTheThrottle(): void + { + $liveProp = Inertia::live('bar', on: new OrderUpdated, throttle: 5000); + + $this->assertSame(5000, $liveProp->liveThrottle()); + } + + public function testUnrelatedMergeAndLivePropsCanRenderTogether(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::merge(['bar']), + 'metrics' => Inertia::live(fn () => ['count' => 1], on: 'order.created', channel: 'metrics'), + ]); + + $this->assertSame(['bar'], $page['props']['orders']); + $this->assertSame(['count' => 1], $page['props']['metrics']); + $this->assertSame(['orders'], $page['mergeProps']); + $this->assertArrayHasKey('metrics', $page['liveProps']); + } + + public function testLiveCanBeCalledMoreThanOnce(): void + { + $prop = (new LiveProp('bar', new OrderUpdated))->live(on: new OrderShipped); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderShipped::class], + ], + [ + 'channel' => ['name' => 'room.1', 'type' => 'presence'], + 'events' => [OrderShipped::class], + ], + ], $prop->liveListeners()); + } + + public function testAnExplicitChannelDoesNotLeakIntoALaterDeclaration(): void + { + $prop = (new LiveProp('bar', new OrderUpdated, 'orders.1'))->live(on: new OrderShipped); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'public'], + 'events' => [OrderUpdated::class], + ], + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderShipped::class], + ], + [ + 'channel' => ['name' => 'room.1', 'type' => 'presence'], + 'events' => [OrderShipped::class], + ], + ], $prop->liveListeners()); + } + + public function testALaterExplicitChannelDoesNotLeakIntoAnEarlierDeclaration(): void + { + $prop = (new LiveProp('bar', new OrderUpdated))->live(on: new OrderShipped, channel: 'stats'); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + [ + 'channel' => ['name' => 'stats', 'type' => 'public'], + 'events' => [OrderShipped::class], + ], + ], $prop->liveListeners()); + } + + public function testAChannelDeclaredWithoutAnEventContributesNothing(): void + { + $prop = (new LiveProp('bar', new OrderUpdated))->live(channel: 'ignored'); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + ], $prop->liveListeners()); + } + + public function testBroadcastOnCanMixBareStringsAndChannelObjects(): void + { + $liveProp = new LiveProp('bar', new OrderBroadcastOnMixed); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders', 'type' => 'public'], + 'events' => [OrderBroadcastOnMixed::class], + ], + [ + 'channel' => ['name' => 'secret', 'type' => 'private'], + 'events' => [OrderBroadcastOnMixed::class], + ], + ], $liveProp->liveListeners()); + } + + public function testBroadcastOnCanReturnABroadcastableModel(): void + { + $liveProp = new LiveProp('bar', new OrderBroadcastOnModel); + + $this->assertSame([ + [ + 'channel' => ['name' => 'orders.9', 'type' => 'public'], + 'events' => [OrderBroadcastOnModel::class], + ], + ], $liveProp->liveListeners()); + } + + #[DataProvider('formattedChannelSources')] + public function testFormattedChannelsAreAdvertisedWithTheirTypes(LiveProp $liveProp, string $event): void + { + $received = []; + + // Insert a prefix after the channel type, the way a tenant prefix keeps + // the type prefix every broadcaster reads first + Broadcaster::formatChannelsUsing(function (array $channels) use (&$received): array { + $received[] = $channels; + + return array_map(function (Channel|string $channel): string { + $name = (string) $channel; + + foreach (['private-encrypted-', 'private-', 'presence-'] as $prefix) { + if (str_starts_with($name, $prefix)) { + return $prefix . 'tenant.' . substr($name, strlen($prefix)); + } + } + + return 'tenant.' . $name; + }, $channels); + }); + + $this->assertSame([ + ['channel' => ['name' => 'tenant.stats', 'type' => 'public'], 'events' => [$event]], + ['channel' => ['name' => 'tenant.orders.1', 'type' => 'private'], 'events' => [$event]], + ['channel' => ['name' => 'tenant.room.1', 'type' => 'presence'], 'events' => [$event]], + ['channel' => ['name' => 'tenant.secret.1', 'type' => 'encrypted-private'], 'events' => [$event]], + ], $liveProp->liveListeners()); + + // The formatter receives the unconverted channels together, as it does when broadcasting + $this->assertCount(1, $received); + $this->assertCount(4, $received[0]); + $this->assertInstanceOf(PrivateChannel::class, $received[0][1]); + } + + /** + * Get the live props whose channels are formatted. + * + * @return array + */ + public static function formattedChannelSources(): array + { + return [ + 'channels the event broadcasts on' => [ + new LiveProp('bar', new OrderBroadcastOnEveryType), + OrderBroadcastOnEveryType::class, + ], + 'the given channels' => [ + new LiveProp('bar', 'order.updated', [ + 'stats', + new PrivateChannel('orders.1'), + new PresenceChannel('room.1'), + new EncryptedPrivateChannel('secret.1'), + ]), + 'order.updated', + ], + ]; + } + + public function testAFormattedBroadcastableModelIsAdvertisedByItsChannelName(): void + { + // Typed to fail if the model reaches the formatter instead of its channel name + Broadcaster::formatChannelsUsing(fn (array $channels): array => array_map( + fn (Channel|string $channel): string => 'tenant.' . $channel, + $channels, + )); + + $liveProp = new LiveProp('bar', new OrderBroadcastOnModel); + + $this->assertSame([ + [ + 'channel' => ['name' => 'tenant.orders.9', 'type' => 'public'], + 'events' => [OrderBroadcastOnModel::class], + ], + ], $liveProp->liveListeners()); + } + + public function testARescuedLivePropDoesNotReEmitItsListeners(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::defer(fn () => throw new RuntimeException('boom'), rescue: true)->live(on: new OrderCreated), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'orders', + ]); + + // The resolver threw, so there is no fresh channel to report. The client + // keeps the listeners it already received, because an omitted prop means + // preserve rather than unsubscribe. + $this->assertContains('orders', $page['rescuedProps']); + $this->assertArrayNotHasKey('orders', $page['liveProps'] ?? []); + } + + public function testARescuedLivePropReturnedFromAClosureIsLeftOutWithItsListeners(): void + { + $page = $this->renderPage([ + 'orders' => fn () => Inertia::defer(fn () => throw new RuntimeException('boom'), rescue: true)->live(on: new OrderCreated), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'orders', + ]); + + $this->assertSame(['orders'], $page['rescuedProps']); + $this->assertArrayNotHasKey('orders', $page['props']); + $this->assertArrayNotHasKey('orders', $page['liveProps'] ?? []); + } + + public function testALivePropThatIsRescuedStillAnnouncesItsListenersOnTheInitialLoad(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::defer(fn () => throw new RuntimeException('boom'), rescue: true)->live(on: new OrderCreated), + ]); + + // The deferred prop is excluded from the initial load before anything is + // resolved, so the client learns what to listen for up front + $this->assertArrayHasKey('orders', $page['liveProps']); + } + + public function testASentLivePropReadsItsChannelsOncePerResponse(): void + { + $initial = new OrderRoutedOnCount; + $page = $this->renderPage([ + 'order' => Inertia::live(fn () => 'bar', on: $initial), + ]); + + $partial = new OrderRoutedOnCount; + $this->renderPage([ + 'order' => Inertia::live(fn () => 'bar', on: $partial), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'order', + ]); + + $this->assertArrayHasKey('order', $page['liveProps']); + $this->assertSame(1, $initial->reads); + $this->assertSame(1, $partial->reads); + } + + public function testTheLiveManifestIsSerializedIntoThePage(): void + { + $page = $this->renderPage([ + 'order' => Inertia::live(fn () => 'bar', on: new OrderUpdated), + ]); + + $this->assertSame('bar', $page['props']['order']); + + $this->assertSame([ + 'order' => [ + 'listeners' => [ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + ], + ], + ], $page['liveProps']); + $this->assertArrayNotHasKey('throttle', $page['liveProps']['order']); + } + + public function testTheLiveManifestSerializesASetThrottle(): void + { + $page = $this->renderPage([ + 'order' => Inertia::live(fn () => 'bar', on: new OrderUpdated, throttle: 500), + ]); + + $this->assertSame([ + 'listeners' => [ + [ + 'channel' => ['name' => 'orders.1', 'type' => 'private'], + 'events' => [OrderUpdated::class], + ], + ], + 'throttle' => 500, + ], $page['liveProps']['order']); + } + + public function testTheLivePropsKeyIsOmittedWhenNoPropsAreLive(): void + { + $page = $this->renderPage(['order' => 'bar']); + + $this->assertArrayNotHasKey('liveProps', $page); + } + + public function testALivePropReturnedFromAClosureIsUnwrapped(): void + { + $page = $this->renderPage([ + 'order' => fn () => Inertia::live('bar', on: new OrderUpdated), + ]); + + $this->assertSame('bar', $page['props']['order']); + $this->assertArrayHasKey('order', $page['liveProps']); + } + + public function testDeferredPropsAreLiveOnTheInitialLoad(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::defer(fn () => ['bar'])->live(on: new OrderCreated), + ]); + + $this->assertArrayNotHasKey('orders', $page['props']); + $this->assertSame(['default' => ['orders']], $page['deferredProps']); + + $this->assertSame([ + 'orders' => [ + 'listeners' => [ + [ + 'channel' => ['name' => 'orders', 'type' => 'public'], + 'events' => ['order.created'], + ], + ], + ], + ], $page['liveProps']); + } + + public function testOptionalPropsAreLiveOnTheInitialLoad(): void + { + $page = $this->renderPage([ + 'order' => Inertia::optional(fn () => 'bar')->live(on: new OrderUpdated), + ]); + + $this->assertArrayNotHasKey('order', $page['props']); + $this->assertArrayHasKey('order', $page['liveProps']); + } + + public function testAlwaysPropsAreLiveOnTheInitialLoad(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::always(fn () => ['bar'])->live(on: new OrderCreated), + ]); + + $this->assertSame(['bar'], $page['props']['orders']); + $this->assertArrayHasKey('orders', $page['liveProps']); + } + + public function testDeferredLivePropsCanStillAppendWithoutMerging(): void + { + $page = $this->renderPage([ + 'orders' => Inertia::defer(fn () => ['items' => ['bar']])->live(on: new OrderCreated)->append('items'), + ]); + + $this->assertArrayNotHasKey('orders', $page['props']); + $this->assertArrayHasKey('orders', $page['liveProps']); + } + + public function testOncePropsTheClientAlreadyLoadedAreStillLive(): void + { + $page = $this->renderPage( + ['order' => Inertia::once(fn () => 'bar')->live(on: new OrderUpdated)], + ['X-Inertia' => 'true', 'X-Inertia-Except-Once-Props' => 'order'], + ); + + $this->assertArrayNotHasKey('order', $page['props']); + $this->assertArrayHasKey('order', $page['liveProps']); + } + + public function testTheLiveManifestIsSerializedOnPartialRequests(): void + { + $page = $this->renderPage([ + 'order' => Inertia::live(fn () => 'bar', on: new OrderUpdated), + 'metrics' => Inertia::live(fn () => 'baz', on: new OrderShipped), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'order', + ]); + + $this->assertSame(['order' => 'bar'], $page['props']); + $this->assertSame(['order'], array_keys($page['liveProps'])); + } + + public function testALivePropResolvedForANestedPartialRequestSendsItsListeners(): void + { + $page = $this->renderPage([ + 'order' => Inertia::live(fn () => ['id' => 2, 'total' => 200], on: new OrderUpdated(2)), + 'metrics' => Inertia::live(fn () => 'baz', on: new OrderShipped), + 'status' => Inertia::live(fn () => 'ready', on: new OrderCreated), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'order.total,status', + 'X-Inertia-Partial-Except' => 'status', + ]); + + // The live prop resolves whole for its nested path, so the listeners for + // its new channel are sent with it + $this->assertSame(['order' => ['id' => 2, 'total' => 200]], $page['props']); + $this->assertSame(['order'], array_keys($page['liveProps'])); + $this->assertSame([['channel' => ['name' => 'orders.2', 'type' => 'private'], 'events' => [OrderUpdated::class]]], $page['liveProps']['order']['listeners']); + } + + public function testLiveAlwaysPropsKeepMetadataOnUnrelatedPartialRequests(): void + { + $page = $this->renderPage([ + 'order' => Inertia::always(fn () => 'bar')->live(on: new OrderUpdated(2)), + 'metrics' => Inertia::live(fn () => 'baz', on: new OrderShipped), + 'status' => Inertia::live(fn () => 'ready', on: new OrderCreated), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'metrics', + ]); + + $this->assertSame(['order' => 'bar', 'metrics' => 'baz'], $page['props']); + $this->assertArrayHasKey('order', $page['liveProps']); + $this->assertSame([['channel' => ['name' => 'orders.2', 'type' => 'private'], 'events' => [OrderUpdated::class]]], $page['liveProps']['order']['listeners']); + $this->assertArrayHasKey('metrics', $page['liveProps']); + $this->assertArrayNotHasKey('status', $page['liveProps']); + } + + public function testLivePropsUnderAnAlwaysParentKeepMetadataOnUnrelatedPartialRequests(): void + { + $page = $this->renderPage([ + 'order' => Inertia::always(fn () => ['total' => Inertia::live(fn () => 100, on: new OrderUpdated(2))]), + 'metrics' => Inertia::live(fn () => 'baz', on: new OrderShipped), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Data' => 'metrics', + ]); + + // The value is sent because its parent resolved outside the filter, so + // its listeners have to be sent with it + $this->assertSame(['order' => ['total' => 100], 'metrics' => 'baz'], $page['props']); + $this->assertArrayHasKey('order.total', $page['liveProps']); + $this->assertSame([['channel' => ['name' => 'orders.2', 'type' => 'private'], 'events' => [OrderUpdated::class]]], $page['liveProps']['order.total']['listeners']); + } + + public function testLivePropsUnderAnAlwaysParentKeepMetadataWhenExcepted(): void + { + $page = $this->renderPage([ + 'order' => Inertia::always(fn () => ['total' => Inertia::live(fn () => 100, on: new OrderUpdated(3))]), + ], [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'User/Edit', + 'X-Inertia-Partial-Except' => 'order', + ]); + + // An always prop outranks [except], so its children are sent and their + // listeners have to be sent too + $this->assertSame(['order' => ['total' => 100]], $page['props']); + $this->assertArrayHasKey('order.total', $page['liveProps']); + } + + public function testNestedLivePropsAreKeyedByTheirDotPath(): void + { + $page = $this->renderPage([ + 'order' => ['total' => Inertia::live(fn () => 100, on: new OrderUpdated)], + ]); + + $this->assertSame(['order' => ['total' => 100]], $page['props']); + $this->assertSame(['order.total'], array_keys($page['liveProps'])); + } + + /** + * Render a page and return its page object. + * + * @param array $props + * @param array $headers + * @return array + */ + protected function renderPage(array $props, array $headers = ['X-Inertia' => 'true']): array + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add($headers); + + $response = new Response('User/Edit', [], $props, 'app', '123'); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + + return $response->getData(true); + } +} diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php index 34c0c0b86f..ce9cde0720 100644 --- a/tests/Inertia/PropsResolverTest.php +++ b/tests/Inertia/PropsResolverTest.php @@ -25,6 +25,7 @@ use JsonSerializable; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; class PropsResolverTest extends TestCase { @@ -233,6 +234,24 @@ public function toInertiaProperty(PropertyContext $prop): mixed $this->assertSame(['stats'], $page['rescuedProps']); } + public function testRescuedDeferPropDoesNotRescueCancellation(): void + { + // A cleanup failure raised while the request is canceled still carries the cancellation + $failure = new RuntimeException('Cleanup failed', previous: new CanceledException('canceled')); + + try { + $this->makePage($this->makePartialRequest('stats'), [ + 'stats' => Inertia::defer(function () use ($failure) { + throw $failure; + }, rescue: true), + ]); + + $this->fail('The cancellation was rescued.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + public function testExcludedPropsAreNotResolvedOnInitialLoad(): void { $optionalResolved = false; diff --git a/tests/Mcp/Unit/Client/OAuth/Concerns/InteractsWithOAuthEndpointsTest.php b/tests/Mcp/Unit/Client/OAuth/Concerns/InteractsWithOAuthEndpointsTest.php index 41652eb6bd..87ab615ff5 100644 --- a/tests/Mcp/Unit/Client/OAuth/Concerns/InteractsWithOAuthEndpointsTest.php +++ b/tests/Mcp/Unit/Client/OAuth/Concerns/InteractsWithOAuthEndpointsTest.php @@ -149,7 +149,7 @@ public function request(): PendingRequest #[TestWith(['https://other.test/mcp'], 'different host')] #[TestWith(['https://mcp.test:8443/mcp'], 'different port')] - public function testResourceHeadersStayOnTheirOriginDuringProbeRedirects(string $redirectUrl): void + public function testResourceHeadersStayRemovedAfterAProbeRedirectLeavesTheirOrigin(string $redirectUrl): void { $requests = []; Http::fake(function ($request) use (&$requests): void { @@ -158,7 +158,8 @@ public function testResourceHeadersStayOnTheirOriginDuringProbeRedirects(string Http::fake([ 'https://mcp.test/mcp' => Http::response('', 307, ['Location' => 'https://mcp.test:443/next']), 'https://mcp.test/next' => Http::response('', 307, ['Location' => $redirectUrl]), - $redirectUrl => Http::response('', 401, [ + $redirectUrl => Http::response('', 307, ['Location' => 'https://mcp.test/return']), + 'https://mcp.test/return' => Http::response('', 401, [ 'WWW-Authenticate' => 'Bearer resource_metadata="https://metadata.test/resource"', ]), ]); @@ -169,10 +170,11 @@ public function testResourceHeadersStayOnTheirOriginDuringProbeRedirects(string ['X-Api-Key' => 'resource-secret'], ); - $this->assertCount(3, $requests); + $this->assertCount(4, $requests); $this->assertTrue($requests[0]->hasHeader('X-Api-Key', 'resource-secret')); $this->assertTrue($requests[1]->hasHeader('X-Api-Key', 'resource-secret')); $this->assertFalse($requests[2]->hasHeader('X-Api-Key')); + $this->assertFalse($requests[3]->hasHeader('X-Api-Key')); } #[TestWith([false], 'environment proxy')] diff --git a/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php b/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php index fd6e4a89e9..9af5f4af3f 100644 --- a/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php @@ -264,7 +264,7 @@ public function testReplacesADefaultHeaderCaseInsensitivelyInsteadOfSendingADupl #[TestWith([false], 'message')] #[TestWith([true], 'session termination')] - public function testRedirectsKeepCustomHeadersOnTheirOrigin(bool $disconnect): void + public function testRedirectsKeepCustomHeadersRemovedAfterLeavingTheirOrigin(bool $disconnect): void { $requests = []; Http::fake(function (Request $request) use (&$requests): PromiseInterface { @@ -297,13 +297,22 @@ public function testRedirectsKeepCustomHeadersOnTheirOrigin(bool $disconnect): v } $this->assertCount(3, $requests); - foreach ([0, 2] as $index) { - $this->assertTrue($requests[$index]->hasHeader('X-Api-Key', 'resource-secret')); + $this->assertTrue($requests[0]->hasHeader('X-Api-Key', 'resource-secret')); + foreach ([1, 2] as $index) { + $this->assertFalse($requests[$index]->hasHeader('X-Api-Key')); $this->assertTrue($requests[$index]->hasHeader('MCP-Session-Id', 'resource-session')); + $this->assertTrue($requests[$index]->hasHeader('MCP-Protocol-Version', ProtocolVersion::V2025_11_25->value)); + } + $this->assertTrue($requests[0]->hasHeader('MCP-Session-Id', 'resource-session')); + + if (! $disconnect) { + $transport->send(json_encode(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'ping'])); + + $this->assertCount(6, $requests); + $this->assertTrue($requests[3]->hasHeader('X-Api-Key', 'resource-secret')); + $this->assertFalse($requests[4]->hasHeader('X-Api-Key')); + $this->assertFalse($requests[5]->hasHeader('X-Api-Key')); } - $this->assertFalse($requests[1]->hasHeader('X-Api-Key')); - $this->assertTrue($requests[1]->hasHeader('MCP-Session-Id', 'resource-session')); - $this->assertTrue($requests[1]->hasHeader('MCP-Protocol-Version', ProtocolVersion::V2025_11_25->value)); } finally { $transport->disconnect(); } diff --git a/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php index 10bed7db80..56f7bac4ca 100644 --- a/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/GrpcInstrumentationTest.php @@ -177,9 +177,10 @@ public function testClientCallInjectsItsExplicitContextWithoutActivatingIt(): vo $this->assertSame($ambient->getContext()->getSpanId(), $span->getParentSpanId()); $this->assertSame($span->getTraceId(), explode('-', $traceparent)[1]); $this->assertSame($span->getSpanId(), explode('-', $traceparent)[2]); + $this->assertSame('https://opentelemetry.io/schemas/1.45.0', $span->getInstrumentationScope()->getSchemaUrl()); $this->assertSame('grpc', $span->getAttributes()->get('rpc.system.name')); $this->assertSame('example.Echo/Say', $span->getAttributes()->get('rpc.method')); - $this->assertSame('OK', $span->getAttributes()->get('rpc.response.status_code')); + $this->assertSame('OK', $span->getAttributes()->get('rpc.status_code')); $this->assertSame('grpc.test', $span->getAttributes()->get(ServerAttributes::SERVER_ADDRESS)); $this->assertSame(50051, $span->getAttributes()->get(ServerAttributes::SERVER_PORT)); $this->assertSame(2, $span->getAttributes()->get('hypervel.grpc.attempt_count')); @@ -187,7 +188,7 @@ public function testClientCallInjectsItsExplicitContextWithoutActivatingIt(): vo $this->metricReader->collect(); $point = $this->histogramPoint(self::CLIENT_DURATION_METRIC); $this->assertSame(3, $point->sum); - $this->assertSame('OK', $point->attributes->get('rpc.response.status_code')); + $this->assertSame('OK', $point->attributes->get('rpc.status_code')); $this->assertNull($point->attributes->get('hypervel.grpc.attempt_count')); } @@ -283,7 +284,7 @@ public function testClientTreatsEveryNonOkStatusAsAnError(): void $span = $this->exportedSpan('example.Echo/Say'); $this->assertSame(TraceStatusCode::STATUS_ERROR, $span->getStatus()->getCode()); - $this->assertSame('INVALID_ARGUMENT', $span->getAttributes()->get('rpc.response.status_code')); + $this->assertSame('INVALID_ARGUMENT', $span->getAttributes()->get('rpc.status_code')); $this->assertSame('INVALID_ARGUMENT', $span->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); $this->metricReader->collect(); diff --git a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php index 7f31230d89..ccf33e3076 100644 --- a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php @@ -45,8 +45,6 @@ use OpenTelemetry\SemConv\Attributes\NetworkAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; use OpenTelemetry\SemConv\Attributes\UrlAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\HttpIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\UrlIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\HttpMetrics; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; @@ -182,9 +180,9 @@ public function testRecordsThePhysicalRequestLifecycleWithoutActivatingTheClient $attributes[UrlAttributes::URL_FULL], ); $this->assertSame('https', $attributes[UrlAttributes::URL_SCHEME]); - $this->assertSame('/users/{user}', $attributes[UrlIncubatingAttributes::URL_TEMPLATE]); - $this->assertSame(12, $attributes[HttpIncubatingAttributes::HTTP_REQUEST_BODY_SIZE]); - $this->assertSame(5, $attributes[HttpIncubatingAttributes::HTTP_RESPONSE_BODY_SIZE]); + $this->assertSame('/users/{user}', $attributes['url.template']); + $this->assertSame(12, $attributes['http.request.body.size']); + $this->assertSame(5, $attributes['http.response.body.size']); $this->assertSame(201, $attributes[HttpAttributes::HTTP_RESPONSE_STATUS_CODE]); $this->assertSame('2', $attributes[NetworkAttributes::NETWORK_PROTOCOL_VERSION]); $this->assertArrayNotHasKey(NetworkAttributes::NETWORK_PROTOCOL_NAME, $attributes); @@ -207,7 +205,7 @@ public function testRecordsThePhysicalRequestLifecycleWithoutActivatingTheClient $this->assertSame('2', $points[0]->attributes->get(NetworkAttributes::NETWORK_PROTOCOL_VERSION)); $this->assertNull($points[0]->attributes->get(UrlAttributes::URL_FULL)); $this->assertNull($points[0]->attributes->get(UrlAttributes::URL_SCHEME)); - $this->assertNull($points[0]->attributes->get(UrlIncubatingAttributes::URL_TEMPLATE)); + $this->assertNull($points[0]->attributes->get('url.template')); } public function testUsesTheExactMethodExposedAtEachSupportedClientBoundary(): void diff --git a/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php index 1acd5a82e5..f3ac334092 100644 --- a/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/HttpServerInstrumentationTest.php @@ -50,8 +50,6 @@ use OpenTelemetry\SemConv\Attributes\NetworkAttributes; use OpenTelemetry\SemConv\Attributes\ServerAttributes; use OpenTelemetry\SemConv\Attributes\UrlAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\HttpIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Metrics\HttpIncubatingMetrics; use OpenTelemetry\SemConv\Metrics\HttpMetrics; use RuntimeException; @@ -121,7 +119,7 @@ public function testRecordsTheCompleteServerLifecycleAndMetrics(): void 'response_headers' => ['x-response'], 'metrics' => [ HttpMetrics::HTTP_SERVER_REQUEST_DURATION => true, - HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS => true, + 'http.server.active_requests' => true, ], ])); @@ -176,8 +174,8 @@ public function testRecordsTheCompleteServerLifecycleAndMetrics(): void $this->assertSame('/users/{user}', $attributes[HttpAttributes::HTTP_ROUTE]); $this->assertSame(201, $attributes[HttpAttributes::HTTP_RESPONSE_STATUS_CODE]); $this->assertSame('safe=yes&token=REDACTED', $attributes[UrlAttributes::URL_QUERY]); - $this->assertSame(12, $attributes[HttpIncubatingAttributes::HTTP_REQUEST_BODY_SIZE]); - $this->assertSame(5, $attributes[HttpIncubatingAttributes::HTTP_RESPONSE_BODY_SIZE]); + $this->assertSame(12, $attributes['http.request.body.size']); + $this->assertSame(5, $attributes['http.response.body.size']); $this->assertArrayNotHasKey(NetworkAttributes::NETWORK_PROTOCOL_NAME, $attributes); $this->assertSame('1.1', $attributes[NetworkAttributes::NETWORK_PROTOCOL_VERSION]); $this->assertArrayNotHasKey(ServerAttributes::SERVER_ADDRESS, $attributes); @@ -199,7 +197,7 @@ public function testRecordsTheCompleteServerLifecycleAndMetrics(): void $this->assertNull($durationPoints[0]->attributes->get(ServerAttributes::SERVER_ADDRESS)); $this->assertNull($durationPoints[0]->attributes->get(ServerAttributes::SERVER_PORT)); - $activeRequests = $this->metric(HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS); + $activeRequests = $this->metric('http.server.active_requests'); $this->assertInstanceOf(Sum::class, $activeRequests->data); $activePoints = $activeRequests->data->dataPoints; $this->assertIsArray($activePoints); @@ -350,7 +348,7 @@ public function testNonRecordingSpansWithoutDurationSkipCompletionDetails(): voi $this->instrumentation()->register($this->options([ 'metrics' => [ HttpMetrics::HTTP_SERVER_REQUEST_DURATION => false, - HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS => true, + 'http.server.active_requests' => true, ], ])); $request = CompletionDetailTrapRequest::create('/users', 'GET'); @@ -361,7 +359,7 @@ public function testNonRecordingSpansWithoutDurationSkipCompletionDetails(): voi $this->assertSame([], $this->spanExporter->getSpans()); $this->assertSame(3_000_000_000, $this->clock->timestamp); $this->metricReader->collect(); - $activeRequests = $this->metric(HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS); + $activeRequests = $this->metric('http.server.active_requests'); $this->assertSame(0, $activeRequests->data->dataPoints[0]->value); } @@ -422,7 +420,7 @@ private function options(array $overrides = []): array 'response_headers' => [], 'metrics' => [ HttpMetrics::HTTP_SERVER_REQUEST_DURATION => true, - HttpIncubatingMetrics::HTTP_SERVER_ACTIVE_REQUESTS => false, + 'http.server.active_requests' => false, ], ], $overrides); } diff --git a/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php index 9ead649a9f..b449ab66a0 100644 --- a/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/PoolInstrumentationTest.php @@ -28,7 +28,6 @@ use OpenTelemetry\SDK\Metrics\MeterProviderBuilder; use OpenTelemetry\SDK\Metrics\MetricExporter\InMemoryExporter; use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader; -use OpenTelemetry\SemConv\Incubating\Metrics\DbIncubatingMetrics; use Swoole\Coroutine\Channel; use WeakReference; @@ -94,6 +93,7 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti $metrics = $this->collect(); $connectionCount = $metrics['db.client.connection.count']; + $this->assertSame('https://opentelemetry.io/schemas/1.45.0', $connectionCount->instrumentationScope->getSchemaUrl()); $this->assertPoint($connectionCount, 2, [ 'db.client.connection.pool.name' => 'database:default', 'db.client.connection.state' => 'idle', @@ -110,10 +110,10 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti 'db.client.connection.pool.name' => 'redis:default', 'db.client.connection.state' => 'used', ]); - $this->assertPoint($metrics['db.client.connection.max'], 10, [ + $this->assertPoint($metrics['db.client.connection.limit'], 10, [ 'db.client.connection.pool.name' => 'database:default', ]); - $this->assertPoint($metrics['db.client.connection.max'], 8, [ + $this->assertPoint($metrics['db.client.connection.limit'], 8, [ 'db.client.connection.pool.name' => 'redis:default', ]); $this->assertPoint($metrics['db.client.connection.pending_requests'], 1, [ @@ -152,13 +152,13 @@ public function testRecordsExistingConnectionAndObjectPoolsWithExactWireIdentiti 'hypervel.object_pool.name' => 'app:reports', 'hypervel.object_pool.state' => 'used', ]); - $this->assertPoint($metrics['hypervel.object_pool.max'], 4, [ + $this->assertPoint($metrics['hypervel.object_pool.limit'], 4, [ 'hypervel.object_pool.name' => $autoIdentity, ]); - $this->assertPoint($metrics['hypervel.object_pool.max'], 5, [ + $this->assertPoint($metrics['hypervel.object_pool.limit'], 5, [ 'hypervel.object_pool.name' => $namedIdentity, ]); - $this->assertPoint($metrics['hypervel.object_pool.max'], 6, [ + $this->assertPoint($metrics['hypervel.object_pool.limit'], 6, [ 'hypervel.object_pool.name' => 'app:reports', ]); $this->assertPoint($metrics['hypervel.object_pool.pending_requests'], 0, [ @@ -260,12 +260,12 @@ public function testIndividualConnectionMetricsReadOnlyTheirRequiredSourceValues $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); $instrumentation->register($this->options( - enabled: [DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX], + enabled: ['db.client.connection.limit'], )); $metrics = $this->collect(); - $this->assertSame([DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX], array_keys($metrics)); - $this->assertPoint($metrics[DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX], 12, [ + $this->assertSame(['db.client.connection.limit'], array_keys($metrics)); + $this->assertPoint($metrics['db.client.connection.limit'], 12, [ 'db.client.connection.pool.name' => 'database:primary', ]); } @@ -283,11 +283,11 @@ public function testIndividualObjectMetricsReadOnlyTheirRequiredSourceValues(): $redisPools->shouldNotReceive('getPools'); $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); - $instrumentation->register($this->options(enabled: ['hypervel.object_pool.max'])); + $instrumentation->register($this->options(enabled: ['hypervel.object_pool.limit'])); $metrics = $this->collect(); - $this->assertSame(['hypervel.object_pool.max'], array_keys($metrics)); - $this->assertPoint($metrics['hypervel.object_pool.max'], 7, [ + $this->assertSame(['hypervel.object_pool.limit'], array_keys($metrics)); + $this->assertPoint($metrics['hypervel.object_pool.limit'], 7, [ 'hypervel.object_pool.name' => 'app:bounded', ]); } @@ -303,7 +303,7 @@ public function testBoundCallbacksStopCollectingAfterTheInstrumentationIsDestroy $instrumentation = $this->instrumentation($databasePools, $redisPools, $objectPools); $reference = WeakReference::create($instrumentation); $instrumentation->register($this->options(enabled: [ - DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT, + 'db.client.connection.count', ])); $this->collect(); @@ -376,11 +376,11 @@ private function connectionPool(int $current, int $idle, int $max, int $waiters) private function options(?array $enabled = null): array { $names = [ - DbIncubatingMetrics::DB_CLIENT_CONNECTION_COUNT, - DbIncubatingMetrics::DB_CLIENT_CONNECTION_MAX, - DbIncubatingMetrics::DB_CLIENT_CONNECTION_PENDING_REQUESTS, + 'db.client.connection.count', + 'db.client.connection.limit', + 'db.client.connection.pending_requests', 'hypervel.object_pool.objects', - 'hypervel.object_pool.max', + 'hypervel.object_pool.limit', 'hypervel.object_pool.pending_requests', ]; diff --git a/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php index b6fe135e84..c7218e4a32 100644 --- a/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/QueueInstrumentationTest.php @@ -66,7 +66,6 @@ use OpenTelemetry\SDK\Trace\TracerProvider; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\MessagingIncubatingAttributes; use Override; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -194,18 +193,16 @@ public function testRecordsOnePersistentProducerLifecycleWithoutActivatingItsSpa Span::fromContext($producerContext)->getContext()->getSpanId(), $span->getSpanId(), ); - $this->assertSame('redis', $attributes->get(MessagingIncubatingAttributes::MESSAGING_SYSTEM)); - $this->assertSame('emails', $attributes->get(MessagingIncubatingAttributes::MESSAGING_DESTINATION_NAME)); - $this->assertSame('enqueue', $attributes->get(MessagingIncubatingAttributes::MESSAGING_OPERATION_NAME)); - $this->assertSame( - MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE_VALUE_SEND, - $attributes->get(MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE), - ); + $this->assertSame('https://opentelemetry.io/schemas/1.45.0', $span->getInstrumentationScope()->getSchemaUrl()); + $this->assertSame('redis', $attributes->get('messaging.system.name')); + $this->assertSame('emails', $attributes->get('messaging.destination.name')); + $this->assertSame('enqueue', $attributes->get('messaging.operation.name')); + $this->assertSame('send', $attributes->get('messaging.operation.type')); $this->assertSame('redis', $attributes->get('hypervel.queue.connection')); - $this->assertSame('job-uuid', $attributes->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID)); + $this->assertSame('job-uuid', $attributes->get('messaging.message.id')); $this->assertSame( strlen($event->payload), - $attributes->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE), + $attributes->get('messaging.message.envelope.size'), ); $this->metricReader->collect(); @@ -213,8 +210,27 @@ public function testRecordsOnePersistentProducerLifecycleWithoutActivatingItsSpa $duration = $this->histogramPoint(self::SEND_DURATION_METRIC); $this->assertSame(1, $sent->value); $this->assertSame(2, $duration->sum); - $this->assertNull($sent->attributes->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID)); - $this->assertNull($duration->attributes->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE)); + $this->assertNull($sent->attributes->get('messaging.message.id')); + $this->assertNull($duration->attributes->get('messaging.message.envelope.size')); + } + + public function testMessagingSystemNameFollowsTheConnectionDriver(): void + { + $this->instrumentation(configuration: $this->configuration([ + 'jobs' => ['driver' => 'sqs'], + 'legacy' => ['driver' => 'beanstalkd'], + ]))->register($this->options()); + + foreach (['jobs', 'legacy'] as $connection) { + $event = new JobPayloadFinalizing($connection, 'emails', 'SendEmail@handle', $this->payload(), null); + $this->events->dispatch($event); + $this->events->dispatch(new JobQueued($connection, 'emails', 42, 'SendEmail@handle', $event->payload, null)); + } + + $this->assertSame(['aws.sqs', 'beanstalk'], array_map( + static fn (SpanDataInterface $span): mixed => $span->getAttributes()->get('messaging.system.name'), + $this->spanExporter->getSpans(), + )); } public function testProducerWithoutUuidCompletesThroughExactPayloadCorrelation(): void @@ -398,7 +414,7 @@ public function testTracingWithoutPropagationKeepsTheOriginalPayloadAndStillCorr $span = $this->exportedSpan('enqueue emails'); $this->assertSame( strlen($originalPayload), - $span->getAttributes()->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE), + $span->getAttributes()->get('messaging.message.envelope.size'), ); $this->assertSame([], $this->metricExporter->collect()); } @@ -562,7 +578,7 @@ public function testLaterFinalizerRewriteFallsBackToTheFrameworkUuid(): void $span = $this->exportedSpan('enqueue emails'); $this->assertSame( strlen($originalPayload), - $span->getAttributes()->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE), + $span->getAttributes()->get('messaging.message.envelope.size'), ); $this->assertNull(QueueProducerStateStore::current()->takeUuid('job-uuid')); } @@ -732,7 +748,7 @@ public function testConsumerSpanUsesTheExtractedProducerAsParentAndRecordsMetric $producerSpanContext = Span::fromContext($producerContext)->getContext(); $this->assertSame($producerSpanContext->getTraceId(), $span->getTraceId()); $this->assertSame($producerSpanContext->getSpanId(), $span->getParentSpanId()); - $this->assertSame('job-uuid', $span->getAttributes()->get(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID)); + $this->assertSame('job-uuid', $span->getAttributes()->get('messaging.message.id')); $this->metricReader->collect(); $this->assertSame(1, $this->sumPoint(self::CONSUMED_MESSAGES_METRIC)->value); @@ -868,17 +884,17 @@ public function testQueueIdentityIsAvailableToTheSamplerWithoutMessageDetails(): $this->assertCount(2, $sampler->samples); foreach ([ - ['sample' => $sampler->samples[0], 'operation' => 'enqueue', 'type' => MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE_VALUE_SEND], - ['sample' => $sampler->samples[1], 'operation' => 'process', 'type' => MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE_VALUE_PROCESS], + ['sample' => $sampler->samples[0], 'operation' => 'enqueue', 'type' => 'send'], + ['sample' => $sampler->samples[1], 'operation' => 'process', 'type' => 'process'], ] as $expected) { $attributes = $expected['sample']['attributes']; - $this->assertSame('redis', $attributes[MessagingIncubatingAttributes::MESSAGING_SYSTEM]); - $this->assertSame('emails', $attributes[MessagingIncubatingAttributes::MESSAGING_DESTINATION_NAME]); - $this->assertSame($expected['operation'], $attributes[MessagingIncubatingAttributes::MESSAGING_OPERATION_NAME]); - $this->assertSame($expected['type'], $attributes[MessagingIncubatingAttributes::MESSAGING_OPERATION_TYPE]); + $this->assertSame('redis', $attributes['messaging.system.name']); + $this->assertSame('emails', $attributes['messaging.destination.name']); + $this->assertSame($expected['operation'], $attributes['messaging.operation.name']); + $this->assertSame($expected['type'], $attributes['messaging.operation.type']); $this->assertSame('redis', $attributes['hypervel.queue.connection']); - $this->assertArrayNotHasKey(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID, $attributes); - $this->assertArrayNotHasKey(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ENVELOPE_SIZE, $attributes); + $this->assertArrayNotHasKey('messaging.message.id', $attributes); + $this->assertArrayNotHasKey('messaging.message.envelope.size', $attributes); } $this->assertSame(0, $instrumentation->consumerStateCount()); @@ -1052,7 +1068,7 @@ public function testDepthCollectionRunsOnlyInEventWorkerZeroAndIsolatesBackendFa $this->assertSame('redis', $points[0]->attributes->get('hypervel.queue.connection')); $this->assertSame( 'emails', - $points[0]->attributes->get(MessagingIncubatingAttributes::MESSAGING_DESTINATION_NAME), + $points[0]->attributes->get('messaging.destination.name'), ); } @@ -1256,7 +1272,7 @@ private function assertProducerWithoutStringUuid(string $payload): void $this->assertSame(1_000_000_000, $span->getStartEpochNanos()); $this->assertSame(3_000_000_000, $span->getEndEpochNanos()); $this->assertFalse( - $span->getAttributes()->has(MessagingIncubatingAttributes::MESSAGING_MESSAGE_ID), + $span->getAttributes()->has('messaging.message.id'), ); $this->assertNull(QueueProducerStateStore::current()->take($event->payload)); diff --git a/tests/OpenTelemetry/ProviderFactoryTest.php b/tests/OpenTelemetry/ProviderFactoryTest.php index 78f69bfb16..128105aee2 100644 --- a/tests/OpenTelemetry/ProviderFactoryTest.php +++ b/tests/OpenTelemetry/ProviderFactoryTest.php @@ -26,9 +26,8 @@ use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter as InMemorySpanExporter; use OpenTelemetry\SDK\Trace\SpanExporterInterface; use OpenTelemetry\SDK\Trace\TracerProvider; +use OpenTelemetry\SemConv\Attributes\DeploymentAttributes; use OpenTelemetry\SemConv\Attributes\ServiceAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\DeploymentIncubatingAttributes; -use OpenTelemetry\SemConv\Incubating\Attributes\ServiceIncubatingAttributes; use RuntimeException; use stdClass; @@ -96,7 +95,7 @@ public function testResourceAttributesApplyExplicitPrecedenceAndUniqueProcessIde ]; $configuration['resource_attributes'] = [ ServiceAttributes::SERVICE_NAME => 'explicit-service', - ServiceIncubatingAttributes::SERVICE_INSTANCE_ID => 'explicit-base', + ServiceAttributes::SERVICE_INSTANCE_ID => 'explicit-base', 'deployment.region' => 'eu-west', 'hypervel.worker.type' => 'spoofed', 'hypervel.worker.id' => 999, @@ -116,13 +115,13 @@ public function testResourceAttributesApplyExplicitPrecedenceAndUniqueProcessIde $this->assertSame('explicit-service', $attributes[ServiceAttributes::SERVICE_NAME]); $this->assertSame('1.2.3', $attributes[ServiceAttributes::SERVICE_VERSION]); - $this->assertSame('testing', $attributes[DeploymentIncubatingAttributes::DEPLOYMENT_ENVIRONMENT_NAME]); + $this->assertSame('testing', $attributes[DeploymentAttributes::DEPLOYMENT_ENVIRONMENT_NAME]); $this->assertSame('eu-west', $attributes['deployment.region']); $this->assertSame('task', $attributes['hypervel.worker.type']); $this->assertSame(5, $attributes['hypervel.worker.id']); $this->assertSame( 'explicit-base:task:5:' . getmypid(), - $attributes[ServiceIncubatingAttributes::SERVICE_INSTANCE_ID], + $attributes[ServiceAttributes::SERVICE_INSTANCE_ID], ); $schemaUrl = $exporters->spanExporter->getSpans()[0]->getResource()->getSchemaUrl(); diff --git a/tests/OpenTelemetry/PublishedConfigurationTest.php b/tests/OpenTelemetry/PublishedConfigurationTest.php index 3edf92667d..5b7e14b1b7 100644 --- a/tests/OpenTelemetry/PublishedConfigurationTest.php +++ b/tests/OpenTelemetry/PublishedConfigurationTest.php @@ -36,7 +36,7 @@ public function testPublishedDefaultsExposeTheCompleteMetricSurface(): void $this->assertSame([ 'db.client.connection.count' => true, - 'db.client.connection.max' => true, + 'db.client.connection.limit' => true, 'db.client.connection.pending_requests' => true, 'db.client.operation.duration' => true, 'http.client.request.duration' => true, @@ -45,7 +45,7 @@ public function testPublishedDefaultsExposeTheCompleteMetricSurface(): void 'hypervel.cache.operations' => true, 'hypervel.console.command.duration' => true, 'hypervel.exceptions' => true, - 'hypervel.object_pool.max' => true, + 'hypervel.object_pool.limit' => true, 'hypervel.object_pool.objects' => true, 'hypervel.object_pool.pending_requests' => true, 'hypervel.queue.jobs' => false, diff --git a/tests/Saloon/Unit/SenderTest.php b/tests/Saloon/Unit/SenderTest.php index 0ae623c608..75ede1fb94 100644 --- a/tests/Saloon/Unit/SenderTest.php +++ b/tests/Saloon/Unit/SenderTest.php @@ -5,14 +5,19 @@ namespace Hypervel\Tests\Saloon\Unit; use GuzzleHttp\Cookie\CookieJarInterface; +use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response as PsrResponse; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Telescope\TelescopeTag; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\Request as HttpRequest; +use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Auth\BasicAuthenticator; +use Hypervel\Saloon\Http\Auth\HeaderAuthenticator; +use Hypervel\Saloon\Http\Auth\MultiAuthenticator; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Request; @@ -25,6 +30,7 @@ use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\RequestInterface; // Hypervel HTTP is Saloon's only transport. Connectors choose a named HTTP connection instead of a sender. @@ -143,6 +149,184 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag(): $this->assertSame([TelescopeTag::Saloon], $capturedOptions['telescope_tags']); } + #[DataProvider('authenticationRedirects')] + public function testAuthenticationHeadersStayOnTheirOrigin( + string $endpoint, + array $locations, + ?string $rewriteHost, + array $authenticatedRequests, + string $authentication = 'built-in', + ): void { + $http = new class extends Factory { + public array $requests = []; + + public array $locations = []; + + /** + * Capture physical requests while exercising the real redirect middleware. + */ + protected function createConnectionHandler(array $options): callable + { + return function (RequestInterface $request, array $options): PromiseInterface { + $this->requests[] = $request; + $location = array_shift($this->locations); + + return Create::promiseFor($location === null + ? new PsrResponse(200) + : new PsrResponse(302, ['Location' => $location])); + }; + } + }; + $http->locations = $locations; + $http->registerConnection('saloon'); + + $request = new class($endpoint, $rewriteHost) extends Request { + protected Method $method = Method::GET; + + public array $hookHeaders = []; + + /** + * Create a request with an optional PSR host override. + */ + public function __construct(private string $endpoint, private ?string $rewriteHost) + { + } + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return $this->endpoint; + } + + /** + * Allow an absolute endpoint to replace the connector's base URL. + */ + public function allowsBaseUrlOverride(): ?bool + { + return true; + } + + /** + * Inspect credentials before applying the application's host override. + */ + public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface + { + $this->hookHeaders[] = $request->getHeaderLine('X-Api-Key'); + + return $this->rewriteHost === null + ? $request + : $request->withUri($request->getUri()->withHost($this->rewriteHost)); + } + }; + $authenticator = $authentication === 'custom' + ? new class implements Authenticator { + /** + * Delegate one credential and add another through the ordinary header API. + */ + public function set(PendingRequest $pendingRequest): void + { + $pendingRequest->authenticate(new HeaderAuthenticator('account-secret', 'X-Account-Secret')); + $pendingRequest->withHeaders(['X-Api-Key' => 'secret']); + } + } + : new MultiAuthenticator( + new HeaderAuthenticator('secret', 'X-Api-Key'), + new HeaderAuthenticator('account-secret', 'X-Account-Secret'), + ); + $connector = $authentication === 'existing' + ? new class extends SenderConnectorStub { + /** + * Supply values that authentication replaces without changing them. + */ + protected function defaultHeaders(): array + { + return ['x-api-key' => 'secret', 'x-account-secret' => 'account-secret']; + } + } + : new SenderConnectorStub; + $request->authenticate($authenticator) + ->withHeader('X-Trace', 'trace'); + $pendingRequest = (new PendingRequest($connector, $request)) + ->applyAuthentication() + ->withHeader('X-After-Authentication', 'ordinary') + ->finalizeUri() + ->prepareBody(); + $sender = new Sender($http, $this->config()); + + $response = $sender->send($pendingRequest, $sender->resolveTransport($pendingRequest)); + + $this->assertCount(count($authenticatedRequests), $http->requests); + foreach ($authenticatedRequests as $index => $authenticated) { + $physicalRequest = $http->requests[$index]; + $this->assertSame($authenticated ? 'secret' : '', $physicalRequest->getHeaderLine('X-Api-Key')); + $this->assertSame($authenticated ? 'account-secret' : '', $physicalRequest->getHeaderLine('X-Account-Secret')); + $this->assertSame('trace', $physicalRequest->getHeaderLine('X-Trace')); + $this->assertSame('ordinary', $physicalRequest->getHeaderLine('X-After-Authentication')); + $this->assertSame($authenticated ? 'secret' : '', $request->hookHeaders[$index]); + } + $this->assertSame(end($http->requests), $response->toPsrRequest()); + $this->assertSame($rewriteHost ?? $pendingRequest->uri()->getHost(), $http->requests[0]->getUri()->getHost()); + } + + /** + * Provide redirect and endpoint origins. + */ + public static function authenticationRedirects(): array + { + return [ + 'another host' => ['/users', ['https://other.example.com/download'], null, [true, false]], + 'custom authenticator' => ['/users', ['https://other.example.com/download'], null, [true, false], 'custom'], + 'unchanged credential values' => ['/users', ['https://other.example.com/download'], null, [true, false], 'existing'], + 'another scheme' => ['/users', ['http://api.example.com/download'], null, [true, false]], + 'another port' => ['/users', ['https://api.example.com:8443/download'], null, [true, false]], + 'same origin' => ['/users', ['/download'], null, [true, true]], + 'absolute endpoint' => ['https://uploads.example.com/files', ['/download'], null, [true, true]], + 'PSR host rewrite' => ['/users', [], 'proxy.example.com', [true]], + 'return to original origin' => ['/users', ['https://other.example.com/download', 'https://api.example.com/result'], null, [true, false, false]], + ]; + } + + public function testARetrySendsCredentialsAgainAfterACrossOriginRedirectChain(): void + { + $http = new class extends Factory { + public array $requests = []; + + /** + * Fail a redirected attempt, then accept the next attempt at the original origin. + */ + protected function createConnectionHandler(array $options): callable + { + return function (RequestInterface $request, array $options): PromiseInterface { + $this->requests[] = $request; + + return Create::promiseFor(match (count($this->requests)) { + 1 => new PsrResponse(302, ['Location' => 'https://other.example.com/download']), + 2 => new PsrResponse(302, ['Location' => 'https://api.example.com/result']), + 3 => new PsrResponse(503), + default => new PsrResponse(200), + }); + }; + } + }; + $http->registerConnection('saloon'); + $this->app->instance(Factory::class, $http); + $request = (new SenderRequestStub) + ->authenticate(new HeaderAuthenticator('secret', 'X-Api-Key')) + ->retry(2, 0); + + $response = (new SenderConnectorStub)->send($request); + + $this->assertSame(200, $response->status()); + $this->assertSame(['secret', '', '', 'secret'], array_map( + static fn (RequestInterface $request): string => $request->getHeaderLine('X-Api-Key'), + $http->requests, + )); + $this->assertSame('/users', $http->requests[3]->getUri()->getPath()); + $this->assertSame('secret', $response->toPsrRequest()->getHeaderLine('X-Api-Key')); + } + /** * Get the package providers. */