Skip to content

Commit 1b16eff

Browse files
committed
feat(sdk): enrich transport events with correlation and timing fields
Send events gain partId (the append idempotency key, also on the server-side record) and bodyBytes. Response events gain client-side attribution: messageId of the last turn-producing send plus sinceSendMs, so time-to-first-token and full-turn latency need no consumer bookkeeping. first-chunk carries chunkType and its record id, turn-completed carries the agent's committed input cursor, and stream-error carries the HTTP status when one exists.
1 parent 0da8323 commit 1b16eff

4 files changed

Lines changed: 130 additions & 42 deletions

File tree

docs/ai-chat/frontend.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,4 +615,4 @@ onEvent: (event) => {
615615
};
616616
```
617617

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.
618+
Time to first token is `first-chunk`'s `sinceSendMs` (the transport tracks the last turn-producing send per chat, so no bookkeeping is needed), and `turn-completed`'s `sinceSendMs` is the full turn latency. Exceptions thrown inside `onEvent` are swallowed, so a failing metrics pipeline can never break the chat.

docs/ai-chat/reference.mdx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -641,15 +641,17 @@ The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.
641641

642642
| Event | Extra fields | Fires when |
643643
| --- | --- | --- |
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. |
644+
| `message-sent` | `messageId?`, `source`, `durationMs`, `partId?`, `bodyBytes?` | 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". `partId` is the append's idempotency key, also stored on the server-side record. |
645+
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
646+
| `stream-connected` | `resumed`, `lastEventId?`, `messageId?` | 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. `lastEventId` is the cursor it connected from. |
647+
| `first-chunk` | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?` | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping. |
648+
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent's committed input-stream cursor. |
649+
| `stream-error` | `error`, `status?` | The output stream failed unrecoverably. |
650650

651651
`source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.
652652

653+
`messageId` on the response-side events is client-side attribution: the id from the most recent turn-producing send (`submit-message`, `regenerate-message`, or `head-start`) on that chat.
654+
653655
See [Monitoring message delivery](/ai-chat/frontend#monitoring-message-delivery) for the metrics and watchdog patterns these enable.
654656

655657
### `accessToken` callback

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

Lines changed: 107 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ import {
2828
controlSubtype,
2929
headerValue,
3030
PUBLIC_ACCESS_TOKEN_HEADER,
31+
SESSION_IN_EVENT_ID_HEADER,
3132
SSEStreamSubscription,
3233
TRIGGER_CONTROL_SUBTYPE,
3334
} from "@trigger.dev/core/v3";
35+
36+
function byteLength(body: string): number {
37+
return new TextEncoder().encode(body).byteLength;
38+
}
3439
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
3540
import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js";
3641
import { slimSubmitMessageForWire } from "./ai-shared.js";
@@ -108,6 +113,10 @@ export type ChatTransportEvent =
108113
messageId?: string;
109114
source: ChatTransportSendSource;
110115
durationMs: number;
116+
/** The append's idempotency key — also stored on the server-side record. */
117+
partId?: string;
118+
/** Serialized request body size in bytes. */
119+
bodyBytes?: number;
111120
}
112121
| {
113122
type: "message-send-failed";
@@ -118,11 +127,41 @@ export type ChatTransportEvent =
118127
error: Error;
119128
status?: number;
120129
durationMs: number;
130+
partId?: string;
131+
bodyBytes?: number;
132+
}
133+
| {
134+
type: "stream-connected";
135+
chatId: string;
136+
timestamp: number;
137+
resumed: boolean;
138+
/** The resume cursor the subscription connected from, if any. */
139+
lastEventId?: string;
140+
/** The last turn-producing send on this chat (client-side attribution). */
141+
messageId?: string;
142+
}
143+
| { type: "stream-error"; chatId: string; timestamp: number; error: Error; status?: number }
144+
| {
145+
type: "first-chunk";
146+
chatId: string;
147+
timestamp: number;
148+
chunkType?: string;
149+
lastEventId?: string;
150+
messageId?: string;
151+
/** Milliseconds since the last turn-producing send on this chat (time to first token). */
152+
sinceSendMs?: number;
121153
}
122-
| { type: "stream-connected"; chatId: string; timestamp: number; resumed: boolean }
123-
| { type: "stream-error"; chatId: string; timestamp: number; error: Error }
124-
| { type: "first-chunk"; chatId: string; timestamp: number }
125-
| { type: "turn-completed"; chatId: string; timestamp: number; lastEventId?: string };
154+
| {
155+
type: "turn-completed";
156+
chatId: string;
157+
timestamp: number;
158+
lastEventId?: string;
159+
/** The agent's committed input-stream cursor from the turn-complete record. */
160+
sessionInEventId?: string;
161+
messageId?: string;
162+
/** Milliseconds since the last turn-producing send on this chat (full turn latency). */
163+
sinceSendMs?: number;
164+
};
126165

127166
/**
128167
* Detect 401/403 from realtime/input-stream calls without relying on `instanceof`
@@ -520,6 +559,9 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
520559
private sessions: Map<string, ChatSessionState> = new Map();
521560
private activeStreams: Map<string, AbortController> = new Map();
522561
private pendingStarts: Map<string, Promise<ChatSessionState>> = new Map();
562+
// Last turn-producing send per chat — attribution source for the
563+
// response-side events' `messageId` / `sinceSendMs`.
564+
private lastTurnSends: Map<string, { messageId?: string; at: number }> = new Map();
523565

524566
constructor(options: TriggerChatTransportOptions) {
525567
this.taskId = options.task;
@@ -687,17 +729,20 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
687729
// Generated outside the closure so auth-retries reuse the same part id
688730
// and the server-side dedupe sees one logical append.
689731
const partId = crypto.randomUUID();
732+
const serializedBody = this.serializeInputChunk({ kind: "message", payload: wirePayload });
690733
const sendChatMessage = async (token: string) => {
691-
await this.appendInputChunk(
692-
chatId,
693-
token,
694-
this.serializeInputChunk({ kind: "message", payload: wirePayload }),
695-
partId
696-
);
734+
await this.appendInputChunk(chatId, token, serializedBody, partId);
697735
};
698736

699-
await this.sendWithEvents(chatId, trigger, messageId ?? messages.at(-1)?.id, () =>
700-
this.callWithAuthRetry(chatId, state, sendChatMessage)
737+
await this.sendWithEvents(
738+
chatId,
739+
trigger,
740+
{
741+
messageId: messageId ?? messages.at(-1)?.id,
742+
partId,
743+
bodyBytes: byteLength(serializedBody),
744+
},
745+
() => this.callWithAuthRetry(chatId, state, sendChatMessage)
701746
);
702747

703748
// Cancel any in-flight stream for this chat — the new turn supersedes it.
@@ -748,18 +793,22 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
748793
};
749794

750795
let response!: Response;
796+
const serializedBody = JSON.stringify(wirePayload);
751797
await this.sendWithEvents(
752798
args.chatId,
753799
"head-start",
754-
args.messageId ?? args.messages.at(-1)?.id,
800+
{
801+
messageId: args.messageId ?? args.messages.at(-1)?.id,
802+
bodyBytes: byteLength(serializedBody),
803+
},
755804
async () => {
756805
response = await fetch(this.headStart!, {
757806
method: "POST",
758807
headers: {
759808
"Content-Type": "application/json",
760809
...this.extraHeaders,
761810
},
762-
body: JSON.stringify(wirePayload),
811+
body: serializedBody,
763812
signal: args.abortSignal,
764813
});
765814

@@ -886,18 +935,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
886935
};
887936

888937
const partId = crypto.randomUUID();
938+
const serializedBody = this.serializeInputChunk({ kind: "message", payload: wirePayload });
889939
const send = async (token: string) => {
890-
await this.appendInputChunk(
891-
chatId,
892-
token,
893-
this.serializeInputChunk({ kind: "message", payload: wirePayload }),
894-
partId
895-
);
940+
await this.appendInputChunk(chatId, token, serializedBody, partId);
896941
};
897942

898943
try {
899-
await this.sendWithEvents(chatId, "steer", message.id, () =>
900-
this.callWithAuthRetry(chatId, state, send)
944+
await this.sendWithEvents(
945+
chatId,
946+
"steer",
947+
{ messageId: message.id, partId, bodyBytes: byteLength(serializedBody) },
948+
() => this.callWithAuthRetry(chatId, state, send)
901949
);
902950
return true;
903951
} catch {
@@ -950,18 +998,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
950998
if (!state) return false;
951999

9521000
const partId = crypto.randomUUID();
1001+
const serializedBody = this.serializeInputChunk({ kind: "stop" });
9531002
const send = async (token: string) => {
954-
await this.appendInputChunk(
955-
chatId,
956-
token,
957-
this.serializeInputChunk({ kind: "stop" }),
958-
partId
959-
);
1003+
await this.appendInputChunk(chatId, token, serializedBody, partId);
9601004
};
9611005

9621006
try {
963-
await this.sendWithEvents(chatId, "stop", undefined, () =>
964-
this.callWithAuthRetry(chatId, state, send)
1007+
await this.sendWithEvents(
1008+
chatId,
1009+
"stop",
1010+
{ partId, bodyBytes: byteLength(serializedBody) },
1011+
() => this.callWithAuthRetry(chatId, state, send)
9651012
);
9661013
} catch {
9671014
return false;
@@ -1015,7 +1062,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
10151062
await this.appendInputChunk(chatId, token, body, partId);
10161063
};
10171064

1018-
await this.sendWithEvents(chatId, "action", undefined, () =>
1065+
await this.sendWithEvents(chatId, "action", { partId, bodyBytes: byteLength(body) }, () =>
10191066
this.callWithAuthRetry(chatId, state, send)
10201067
);
10211068

@@ -1081,36 +1128,48 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
10811128
private async sendWithEvents(
10821129
chatId: string,
10831130
source: ChatTransportSendSource,
1084-
messageId: string | undefined,
1131+
extras: { messageId?: string; partId?: string; bodyBytes?: number },
10851132
op: () => Promise<void>
10861133
): Promise<void> {
10871134
const startedAt = Date.now();
10881135
try {
10891136
await op();
1137+
const turnProducing =
1138+
source === "submit-message" || source === "regenerate-message" || source === "head-start";
1139+
if (turnProducing) {
1140+
this.lastTurnSends.set(chatId, { messageId: extras.messageId, at: Date.now() });
1141+
}
10901142
this.emitEvent({
10911143
type: "message-sent",
10921144
chatId,
10931145
timestamp: Date.now(),
1094-
messageId,
10951146
source,
10961147
durationMs: Date.now() - startedAt,
1148+
...extras,
10971149
});
10981150
} catch (error) {
10991151
const status = (error as { status?: unknown }).status;
11001152
this.emitEvent({
11011153
type: "message-send-failed",
11021154
chatId,
11031155
timestamp: Date.now(),
1104-
messageId,
11051156
source,
11061157
error: error instanceof Error ? error : new Error(String(error)),
11071158
status: typeof status === "number" ? status : undefined,
11081159
durationMs: Date.now() - startedAt,
1160+
...extras,
11091161
});
11101162
throw error;
11111163
}
11121164
}
11131165

1166+
/** Attribution fields for response-side events. */
1167+
private turnAttribution(chatId: string): { messageId?: string; sinceSendMs?: number } {
1168+
const lastSend = this.lastTurnSends.get(chatId);
1169+
if (!lastSend) return {};
1170+
return { messageId: lastSend.messageId, sinceSendMs: Date.now() - lastSend.at };
1171+
}
1172+
11141173
/**
11151174
* Update the transport's `clientData`. Used by `useTriggerChatTransport`
11161175
* to keep the latest value reachable from inside `startSession` and
@@ -1547,6 +1606,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
15471606
chatId,
15481607
timestamp: Date.now(),
15491608
resumed: options?.resumed ?? false,
1609+
lastEventId: state.lastEventId,
1610+
messageId: this.lastTurnSends.get(chatId)?.messageId,
15501611
});
15511612
let sawFirstChunk = false;
15521613

@@ -1634,6 +1695,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16341695
chatId,
16351696
timestamp: Date.now(),
16361697
lastEventId: state.lastEventId,
1698+
sessionInEventId: headerValue(value.headers, SESSION_IN_EVENT_ID_HEADER),
1699+
...this.turnAttribution(chatId),
16371700
});
16381701
state.isStreaming = false;
16391702
this.notifySessionChange(chatId, state);
@@ -1659,7 +1722,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16591722
if (value.chunk == null) continue;
16601723
if (!sawFirstChunk) {
16611724
sawFirstChunk = true;
1662-
this.emitEvent({ type: "first-chunk", chatId, timestamp: Date.now() });
1725+
this.emitEvent({
1726+
type: "first-chunk",
1727+
chatId,
1728+
timestamp: Date.now(),
1729+
chunkType: (value.chunk as { type?: string }).type,
1730+
lastEventId: value.id || undefined,
1731+
...this.turnAttribution(chatId),
1732+
});
16631733
}
16641734
controller.enqueue(value.chunk as UIMessageChunk);
16651735
}
@@ -1672,11 +1742,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
16721742
}
16731743
return;
16741744
}
1745+
const errorStatus = (error as { status?: unknown }).status;
16751746
this.emitEvent({
16761747
type: "stream-error",
16771748
chatId,
16781749
timestamp: Date.now(),
16791750
error: error instanceof Error ? error : new Error(String(error)),
1751+
status: typeof errorStatus === "number" ? errorStatus : undefined,
16801752
});
16811753
controller.error(error);
16821754
} finally {

packages/trigger-sdk/test/chat-transport-events.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,23 @@ describe("transport send events", () => {
8383
expect(sent.source).toBe("submit-message");
8484
expect(sent.durationMs).toBeGreaterThanOrEqual(0);
8585
expect(sent.timestamp).toBeGreaterThan(0);
86+
expect(sent.partId).toMatch(/[0-9a-f-]{36}/);
87+
expect(sent.bodyBytes).toBeGreaterThan(0);
8688

8789
const connected = events[1] as Extract<ChatTransportEvent, { type: "stream-connected" }>;
8890
expect(connected.resumed).toBe(false);
91+
expect(connected.messageId).toBe("u-1");
92+
93+
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
94+
expect(firstChunk.chunkType).toBe("text-delta");
95+
expect(firstChunk.lastEventId).toBe("1");
96+
expect(firstChunk.messageId).toBe("u-1");
97+
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
98+
99+
const turnCompleted = events[3] as Extract<ChatTransportEvent, { type: "turn-completed" }>;
100+
expect(turnCompleted.messageId).toBe("u-1");
101+
expect(turnCompleted.sinceSendMs).toBeGreaterThanOrEqual(0);
102+
expect(turnCompleted.lastEventId).toBe("2");
89103
});
90104

91105
it("emits message-send-failed with the HTTP status when the append fails", async () => {

0 commit comments

Comments
 (0)