> ## Documentation Index
> Fetch the complete documentation index at: https://docs.instructorphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Events

Polyglot uses an event system to provide observability into the internal execution pipeline. Events are dispatched at each stage of the lifecycle, making it straightforward to implement logging, metrics, debugging, and monitoring without modifying the core library.

## Listening to Events

Both inference and embeddings runtimes expose two ways to listen to events:

### Targeted Listeners

Use `onEvent()` to listen for a specific event class:

```php theme={null}
use Cognesy\Polyglot\Inference\Config\LLMConfig;
use Cognesy\Polyglot\Inference\Events\InferenceResponseCreated;
use Cognesy\Polyglot\Inference\InferenceRuntime;

$runtime = InferenceRuntime::fromConfig(
    new LLMConfig(
        driver: 'openai',
        apiUrl: 'https://api.openai.com/v1',
        apiKey: getenv('OPENAI_API_KEY'),
        endpoint: '/chat/completions',
        model: 'gpt-4.1-nano',
    ),
)->onEvent(InferenceResponseCreated::class, function ($event): void {
    // Log or inspect the response
});
// @doctest id="9348"
```

You can register multiple listeners for the same event class. An optional priority parameter controls the order (higher values run first):

```php theme={null}
$runtime->onEvent(InferenceStarted::class, $highPriorityListener, priority: 10);
$runtime->onEvent(InferenceStarted::class, $lowPriorityListener, priority: 0);
// @doctest id="d4f7"
```

### Wiretap

Use `wiretap()` to receive all events regardless of type. This is useful for debugging and general-purpose logging:

```php theme={null}
$runtime->wiretap(function ($event): void {
    echo get_class($event) . "\n";
});
// @doctest id="afd2"
```

## Inference Events

The inference lifecycle dispatches events in this order:

### Execution-Level Events

| Event                | When Dispatched                       | Key Data                                                                                                                             |
| -------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `InferenceStarted`   | Beginning of execution                | `data['executionId']`, `data['requestId']`, `data['isStreamed']`, `data['model']`, `data['messageCount']`                            |
| `InferenceCompleted` | End of execution (success or failure) | `data['executionId']`, `data['isSuccess']`, `data['finishReason']`, `data['attemptCount']`, `data['durationMs']`, token-count fields |

These events bracket the entire inference operation, including any retry attempts. `InferenceCompleted` is dispatched exactly once per execution, whether it succeeded or failed.

### Attempt-Level Events

Each retry attempt dispatches its own events:

| Event                       | When Dispatched                | Key Data                                                                                                                                                                                                          |
| --------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `InferenceAttemptStarted`   | Beginning of an attempt        | execution ID, attempt ID, attempt number, model                                                                                                                                                                   |
| `InferenceAttemptSucceeded` | Attempt completed successfully | `data['executionId']`, `data['attemptId']`, `data['attemptNumber']`, `data['finishReason']`, `data['durationMs']`, token-count fields                                                                             |
| `InferenceAttemptFailed`    | Attempt failed                 | `data['executionId']`, `data['attemptId']`, `data['attemptNumber']`, `data['errorMessage']`, `data['errorType']`, `data['willRetry']`, `data['httpStatusCode']`, partial token-count fields, `data['durationMs']` |
| `InferenceUsageReported`    | After a successful attempt     | `data['executionId']`, `data['model']`, `data['isFinal']`, token-count fields                                                                                                                                     |

When retries are configured, you may see multiple `InferenceAttemptStarted`/`InferenceAttemptFailed` pairs before a final `InferenceAttemptSucceeded` event. The `attemptNumber` field tracks which attempt is running.

### Response Events

| Event                      | When Dispatched                          | Key Data                                                                                                                                                                                                                                                                                                                  |
| -------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `InferenceRequested`       | Before sending the HTTP request          | request data                                                                                                                                                                                                                                                                                                              |
| `InferenceResponseCreated` | After receiving and parsing the response | `data['executionId']`, `data['requestId']`, `data['model']`, `data['responseId']`, `data['finishReason']`, `data['contentLength']`, `data['reasoningContentLength']`, `data['hasToolCalls']`, `data['toolCallCount']`, `data['usage']`, `data['isPartial']`, and `data['statusCode']` when the response carries HTTP data |
| `InferenceFailed`          | On unrecoverable failure                 | error details                                                                                                                                                                                                                                                                                                             |

`InferenceResponseCreated` is emitted from two places — the driver, for a non-streamed response, and `InferenceStream`, when a stream finalises. **The key set is the same either way**; both use a single payload builder (`Inference\Core\InferenceResponseEventPayload`), so the two cannot drift.

One value does differ. `data['executionId']` is populated on the streamed path and is **`null` on the non-streamed path** — the driver is shared across executions by `InferenceRuntime`, so it has no execution to name. Correlate a non-streamed response by `data['requestId']`, which both paths carry and which `InferenceStarted` and `InferenceCompleted` report alongside their `executionId`.

`data['statusCode']` is omitted, on both paths alike, when the response carries no HTTP status — for example a response assembled purely from stream deltas.

### Streaming Events

| Event                          | When Dispatched               | Key Data                                                             |
| ------------------------------ | ----------------------------- | -------------------------------------------------------------------- |
| `StreamFirstChunkReceived`     | First visible delta arrives   | execution ID, timeToFirstChunkMs, receivedAt, model, initial content |
| `PartialInferenceDeltaCreated` | Each visible delta            | `data['executionId']`, `data['contentDelta']`                        |
| `StreamEventReceived`          | Raw SSE event received        | raw event data                                                       |
| `StreamEventParsed`            | SSE event parsed into a delta | parsed event data                                                    |

The `StreamFirstChunkReceived` event is particularly useful for measuring time-to-first-chunk (TTFC), as it includes the `requestStartedAt` timestamp.

### Driver Events

| Event                  | When Dispatched                            | Key Data                                         |
| ---------------------- | ------------------------------------------ | ------------------------------------------------ |
| `InferenceDriverBuilt` | After the driver is created by the factory | driver class, redacted config, HTTP client class |

Sensitive configuration values (API keys, tokens, secrets) are automatically redacted in the `InferenceDriverBuilt` event payload.

## Embeddings Events

The embeddings lifecycle dispatches a smaller set of events:

| Event                        | When Dispatched                        | Key Data                                                                                            |
| ---------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `EmbeddingsDriverBuilt`      | After the embeddings driver is created | driver class, config, HTTP client class                                                             |
| `EmbeddingsRequested`        | Before sending the embeddings request  | request data                                                                                        |
| `EmbeddingsResponseReceived` | After receiving the response           | `data['model']`, `data['inputCount']`, `data['vectorCount']`, `data['dimensions']`, `data['usage']` |
| `EmbeddingsFailed`           | On failure                             | error details                                                                                       |

## Practical Examples

### Logging Token Usage

```php theme={null}
use Cognesy\Polyglot\Inference\Events\InferenceUsageReported;

$runtime->onEvent(InferenceUsageReported::class, function ($event): void {
    logger()->info('Token usage', [
        'model' => $event->data['model'] ?? null,
        'inputTokens' => $event->data['inputTokens'] ?? 0,
        'outputTokens' => $event->data['outputTokens'] ?? 0,
        'totalTokens' => $event->data['totalTokens'] ?? 0,
    ]);
});
// @doctest id="4ada"
```

### Measuring Time-to-First-Chunk

```php theme={null}
use Cognesy\Polyglot\Inference\Events\StreamFirstChunkReceived;

$runtime->onEvent(StreamFirstChunkReceived::class, function (StreamFirstChunkReceived $event): void {
    logger()->info("TTFC: {$event->timeToFirstChunkMs}ms for model {$event->model}");
});
// @doctest id="c47e"
```

### Tracking Retry Attempts

```php theme={null}
use Cognesy\Polyglot\Inference\Events\InferenceAttemptFailed;

$runtime->onEvent(InferenceAttemptFailed::class, function (InferenceAttemptFailed $event): void {
    logger()->warning('Attempt failed', [
        'attemptNumber' => $event->data['attemptNumber'] ?? null,
        'errorMessage' => $event->data['errorMessage'] ?? null,
        'errorType' => $event->data['errorType'] ?? null,
        'willRetry' => $event->data['willRetry'] ?? false,
        'httpStatus' => $event->data['httpStatusCode'] ?? null,
    ]);
});
// @doctest id="472f"
```

### Monitoring Execution Outcomes

```php theme={null}
use Cognesy\Polyglot\Inference\Events\InferenceCompleted;

$runtime->onEvent(InferenceCompleted::class, function (InferenceCompleted $event): void {
    logger()->info('Inference completed', [
        'success' => $event->data['isSuccess'] ?? false,
        'finishReason' => $event->data['finishReason'] ?? null,
        'attempts' => $event->data['attemptCount'] ?? 0,
        'totalTokens' => $event->data['totalTokens'] ?? 0,
        'durationMs' => $event->data['durationMs'] ?? 0,
    ]);
});
// @doctest id="25b5"
```

## Event Dispatcher

Events are dispatched through an `EventDispatcher` that implements `CanHandleEvents` (which extends `Psr\EventDispatcher\EventDispatcherInterface`). When a runtime is created without an explicit event dispatcher, it creates a default one named `'polyglot.inference.runtime'` or `'polyglot.embeddings.runtime'`.

You can inject a shared event dispatcher to correlate events across multiple runtimes or integrate with your application's existing event system:

```php theme={null}
use Cognesy\Events\Dispatchers\EventDispatcher;

$events = new EventDispatcher(name: 'my-app');
$runtime = InferenceRuntime::fromConfig($config, events: $events);
// @doctest id="43c0"
```

The same event dispatcher instance can be shared between inference and embeddings runtimes, allowing a single wiretap listener to observe all Polyglot activity.

## Listener Gating

Some events are **not constructed at all** when nothing is listening for them. Building an event is not free — the base `Event` generates a UUID and a `DateTimeImmutable` (\~0.9µs), and the payload arrays cost more on top: `InferenceRequested` walks the message list, tools and options; `InferenceResponseCreated` runs `strlen()` over the full content; the `InferenceFailed` payloads run header/body redaction. On the streaming path the per-delta events would pay that cost thousands of times per response.

Gating is decided by `Cognesy\Events\Support\ListenerGate`, the single definition of the rule:

```php theme={null}
ListenerGate::wants($events, PartialInferenceDeltaCreated::class);
// @doctest id="0ac3"
```

Two properties matter to anyone writing a dispatcher or a listener.

### Fail-open is contractual

A dispatcher is only asked about its listeners if it implements `Cognesy\Events\Contracts\CanCheckListeners`. **A plain PSR-14 `EventDispatcherInterface` cannot report its listeners, so it is assumed to listen and receives every event.** No dispatcher ever loses an event to this optimisation — the worst case is that the payload is built and discarded. The built-in `EventDispatcher` does implement `CanCheckListeners`, so it gets the gating.

### The gate is resolved once, at construction

Emitters resolve the answer in their constructor and store it in a `readonly bool`; they do not re-check per dispatch, because that would put an `instanceof` back on the hot path. The deliberate consequence:

> **A listener registered after the emitter was constructed is not observed by that emitter.**

For a `wiretap()` or `onEvent()` call made on the runtime before the request is sent, this is invisible — the emitters do not exist yet. It becomes visible if you register a listener *from inside another listener* mid-request, or attach one to a shared dispatcher while a stream is already being consumed: gated events already resolved as "unwanted" stay unwanted for the rest of that stream. Register listeners before starting the operation you want to observe.

### What is gated

| Emitter                      | Events                                                                                                                                               |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `InferenceExecutionSession`  | `InferenceStarted`, `InferenceAttemptStarted`, `InferenceAttemptSucceeded`, `InferenceUsageReported`, `InferenceAttemptFailed`, `InferenceCompleted` |
| `BaseInferenceRequestDriver` | `InferenceRequested`, `InferenceResponseCreated`, `InferenceFailed`                                                                                  |
| `BaseEmbedDriver`            | `EmbeddingsRequested`, `EmbeddingsFailed`                                                                                                            |
| `InferenceStream`            | `PartialInferenceDeltaCreated`                                                                                                                       |
| `EventStreamReader`          | `StreamEventReceived`, `StreamEventParsed`                                                                                                           |

Everything else is dispatched unconditionally. `packages/instructor` applies the same rule to
its own structured-output lifecycle emitters — see the "Listener Gating" section of
`packages/instructor/docs/internals/events.md`.

The lifecycle events matter most. Each one carries a telemetry envelope under
`data['telemetry']`, and four of the six build it by serialising the **entire conversation**
via `Messages::toArray()`. That cost scales with conversation length, not response length:
for a 128 KB conversation it was \~534 µs per request. With no listeners a session now costs
a flat \~14 µs regardless of how long the conversation is.

When listeners *are* attached, the conversation is serialised **once per request rather than
four times**. `Cognesy\Polyglot\Telemetry\MessagesSerializationMemo` holds the two most recent
results in fixed slots keyed on the `Messages` instance, so the four envelope sites share
one serialisation — \~166 µs instead of \~534 µs under a wiretap on a 128 KB conversation. The
key is the object, not its content: a conversation rewritten mid-session by length recovery
is a different instance and is serialised afresh.

Two slots rather than one because `packages/instructor` uses the same memo and interleaves
two conversations per request — its own, and the materialized one handed to the nested
inference call. Across a structured-output request that takes the count from 7 to 2. Deeper
nesting simply misses and re-serialises: a lost optimisation, never a wrong answer.

Only the payload and the dispatch are conditional. Timing, attempt numbering and execution
state are not — `durationMs` is correct whether or not anyone was listening.
