Skip to content

Commit 0da8323

Browse files
committed
feat(sdk): onEvent observability callback on the chat transport
Typed lifecycle events (message-sent, message-send-failed, stream-connected, first-chunk, turn-completed, stream-error) emitted from the transport's send and stream paths, including headStart and steering sends the fetch override cannot see. Send events are terminal and durable-ack semantics; callback exceptions are swallowed. The React hook keeps the callback live across renders.
1 parent 76c37ec commit 0da8323

6 files changed

Lines changed: 463 additions & 17 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Add an `onEvent` observability callback to `TriggerChatTransport` / `useTriggerChatTransport` that emits typed lifecycle events: `message-sent` and `message-send-failed` (durable send outcomes with source and duration), `stream-connected`, `first-chunk`, `turn-completed`, and `stream-error`. Together these make send-success metrics, time-to-first-token, and "sent but never answered" watchdogs a few lines of client code.
6+
7+
```ts
8+
const transport = useTriggerChatTransport({
9+
task: "my-chat",
10+
accessToken,
11+
onEvent: (event) => {
12+
if (event.type === "message-sent") {
13+
metrics.timing("chat.send_duration_ms", event.durationMs);
14+
}
15+
},
16+
});
17+
```

docs/ai-chat/frontend.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,45 @@ baseURL: ({ endpoint }) =>
574574
```
575575

576576
For per-request control beyond URL routing (header injection, custom retries, tracing), pass a `fetch` override. See [Trusted edge signals](/ai-chat/patterns/trusted-edge-signals) for a full proxy walkthrough.
577+
578+
## Monitoring message delivery
579+
580+
`sendMessage` from `useChat` gives no feedback about whether the message actually reached the backend. The transport's `onEvent` callback closes that gap with typed lifecycle events (see the [event catalog](/ai-chat/reference#transport-events)) so you can record real metrics:
581+
582+
```ts
583+
const transport = useTriggerChatTransport({
584+
task: "my-chat",
585+
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
586+
onEvent: (event) => {
587+
switch (event.type) {
588+
case "message-sent":
589+
metrics.increment("chat.message_sent");
590+
metrics.timing("chat.send_duration_ms", event.durationMs);
591+
break;
592+
case "message-send-failed":
593+
metrics.increment("chat.message_send_failed", { status: event.status });
594+
break;
595+
}
596+
},
597+
});
598+
```
599+
600+
A `message-sent` event means the message is durably written to the session's input stream (the stream the agent consumes from), so it's a true "sent successfully" signal. Because send and response events share the same callback, "sent but never answered" becomes a small client-side watchdog:
601+
602+
```ts
603+
const pending = new Map<string, ReturnType<typeof setTimeout>>();
604+
605+
onEvent: (event) => {
606+
if (event.type === "message-sent" && event.source === "submit-message") {
607+
pending.set(event.chatId, setTimeout(() => {
608+
metrics.increment("chat.sent_but_unanswered", { chatId: event.chatId });
609+
}, 30_000));
610+
}
611+
if (event.type === "first-chunk" || event.type === "turn-completed" || event.type === "stream-error") {
612+
clearTimeout(pending.get(event.chatId));
613+
pending.delete(event.chatId);
614+
}
615+
};
616+
```
617+
618+
Time to first token is the delta between a `message-sent` and the following `first-chunk` on the same chat. Exceptions thrown inside `onEvent` are swallowed, so a failing metrics pipeline can never break the chat.

docs/ai-chat/reference.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,10 +630,28 @@ Options for the frontend transport constructor and `useTriggerChatTransport` hoo
630630
| `clientData` | Typed by `clientDataSchema` || Default client data merged into per-turn `metadata` and threaded through `startSession`'s params (so the first run's `payload.metadata` matches per-turn `metadata`). Live-updated when the option value changes. |
631631
| `sessions` | `Record<string, ChatSession>` || Restore sessions from storage. See [ChatSession](#chatsession). |
632632
| `onSessionChange` | `(chatId, session \| null) => void` || Fires when session state changes. `session` is the full `ChatSession` or `null` when the run ends. |
633+
| `onEvent` | `(event: ChatTransportEvent) => void` || Observability callback for transport lifecycle events (send outcomes, stream connects, first chunk, turn completion). See [Transport events](#transport-events). Exceptions thrown by the callback are swallowed. |
633634
| `multiTab` | `boolean` | `false` | Enable multi-tab claim coordination via `BroadcastChannel`. See [Frontend → multi-tab](/ai-chat/frontend#multi-tab-coordination). |
634635
| `watch` | `boolean` | `false` | Read-only watcher mode — keep the SSE subscription open across `trigger:turn-complete` so a viewer sees turns 2, 3, … through one long-lived stream. |
635636
| `headStart` | `string` || URL of a [`chat.headStart`](/ai-chat/fast-starts#head-start) route handler. When set, the FIRST message of a brand-new chat POSTs to this URL so step 1's LLM call runs in your warm process while the agent run boots in parallel. Subsequent turns bypass it. |
636637

638+
### Transport events
639+
640+
The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.dev/sdk/chat` and `@trigger.dev/sdk/chat/react`) for each lifecycle moment the transport observes. All events carry `chatId` and `timestamp`.
641+
642+
| Event | Extra fields | Fires when |
643+
| --- | --- | --- |
644+
| `message-sent` | `messageId?`, `source`, `durationMs` | A send was durably acknowledged — a 2xx from the session input stream append (or the `headStart` POST), after any internal token-refresh retries. This means the message is durably written to the stream the agent consumes from, not merely "request accepted". |
645+
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
646+
| `stream-connected` | `resumed` | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. |
647+
| `first-chunk` || The first response chunk of a turn arrived. Pair with `message-sent` for time-to-first-token. |
648+
| `turn-completed` | `lastEventId?` | The agent's turn-complete control record arrived — the "finished answering" signal. |
649+
| `stream-error` | `error` | The output stream failed unrecoverably. |
650+
651+
`source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.
652+
653+
See [Monitoring message delivery](/ai-chat/frontend#monitoring-message-delivery) for the metrics and watchdog patterns these enable.
654+
637655
### `accessToken` callback
638656

639657
The transport invokes `accessToken` whenever it needs a *fresh* session-scoped PAT — initial use after no PAT is cached, or after a 401/403 from any session-PAT-authed request. The callback's job is to **return a token, not to start a run.**

packages/trigger-sdk/src/v3/chat-react.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type UseTriggerChatTransportOptions<TTask extends AnyTask = AnyTask> = Om
5050
};
5151

5252
export type { InferChatUIMessage };
53+
export type { ChatTransportEvent, ChatTransportSendSource } from "./chat.js";
5354

5455
/**
5556
* React hook that creates and memoizes a `TriggerChatTransport` instance.
@@ -90,11 +91,15 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
9091
}
9192

9293
// Keep callbacks up to date without recreating the transport.
93-
const { onSessionChange, clientData } = options;
94+
const { onSessionChange, clientData, onEvent } = options;
9495
useEffect(() => {
9596
ref.current?.setOnSessionChange(onSessionChange);
9697
}, [onSessionChange]);
9798

99+
useEffect(() => {
100+
ref.current?.setOnEvent(onEvent);
101+
}, [onEvent]);
102+
98103
// Keep `clientData` up to date so the transport's per-turn merge and
99104
// `startSession` callback both see the latest value without
100105
// reconstructing the transport.

0 commit comments

Comments
 (0)