Skip to content

Commit 14c7905

Browse files
authored
feat: improve the event system to emit more data and conventionalize … (TanStack#239)
* feat: improve the event system to emit more data and conventionalize the naming * chore: fixup code rabbit
1 parent 65a6796 commit 14c7905

25 files changed

Lines changed: 2397 additions & 1190 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ A powerful, type-safe AI SDK for building AI-powered applications.
4545
- Headless chat state management with adapters (SSE, HTTP stream, custom)
4646
- Isomorphic type-safe tools with server/client execution
4747
- **Enhanced integration with TanStack Start** - Share implementations between AI tools and server functions
48+
- **Observability events** - Structured, typed events for text, tools, image, speech, transcription, and video ([docs](./docs/guides/observability.md))
4849

4950
### <a href="https://tanstack.com/ai">Read the docs →</b></a>
5051

docs/guides/observability.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ the `{ withEventTarget: true }` option.
1818
This will not only emit to the event bus (which is not present in production), but to the current eventTarget that
1919
you will be able to listen to.
2020

21+
## Event naming scheme
22+
23+
Events follow the format `<system-part>:<what-it-does>`.
24+
25+
- Text: `text:request:started`, `text:message:created`, `text:chunk:content`, `text:usage`
26+
- Tools: `tools:approval:requested`, `tools:call:completed`, `tools:result:added`
27+
- Summarize: `summarize:request:started`, `summarize:usage`
28+
- Image: `image:request:started`, `image:usage`
29+
- Speech: `speech:request:started`, `speech:usage`
30+
- Transcription: `transcription:request:started`, `transcription:usage`
31+
- Video: `video:request:started`, `video:usage`
32+
- Client: `client:created`, `client:loading:changed`, `client:messages:cleared`
33+
34+
Every event includes all metadata available at the time of emission (model, provider,
35+
system prompts, request and message IDs, options, and tool names).
36+
2137
## Server events
2238

2339
There are both events that happen on the server and on the client, if you want to listen to either side you just need to
@@ -28,7 +44,7 @@ Here is an example for the server:
2844
import { aiEventClient } from "@tanstack/ai/event-client";
2945

3046
// server.ts file or wherever the root of your server is
31-
aiEventClient.on("chat:started", e => {
47+
aiEventClient.on("text:request:started", e => {
3248
// implement whatever you need to here
3349
})
3450
// rest of your server logic
@@ -46,7 +62,7 @@ import { aiEventClient } from "@tanstack/ai/event-client";
4662

4763
const App = () => {
4864
useEffect(() => {
49-
const cleanup = aiEventClient.on("client:tool-call-updated", e => {
65+
const cleanup = aiEventClient.on("tools:call:updated", e => {
5066
// do whatever you need to do
5167
})
5268
return cleanup;
@@ -55,4 +71,17 @@ const App = () => {
5571
}
5672
```
5773

74+
## Reconstructing chat
75+
76+
To rebuild a chat timeline from events, listen for:
77+
78+
- `text:message:created` (full message content)
79+
- `text:message:user` (explicit user message events)
80+
- `text:chunk:*` (streaming content, tool calls, tool results, thinking)
81+
- `tools:*` (approvals, input availability, call completion)
82+
- `text:request:completed` (final completion + usage)
83+
84+
This set is sufficient to replay the conversation end-to-end for observability and
85+
telemetry systems.
86+
5887

examples/ts-react-chat/src/routes/api.tanchat.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ export const Route = createFileRoute('/api/tanchat')({
9494
}),
9595
openrouter: () =>
9696
createChatOptions({
97-
adapter: openRouterText('openrouter/auto'),
97+
adapter: openRouterText('openai/gpt-5.1'),
9898
modelOptions: {
9999
models: ['openai/chatgpt-4o-latest'],
100100
route: 'fallback',
101+
reasoning: {
102+
effort: 'medium',
103+
},
101104
},
102105
}),
103106
gemini: () =>

packages/typescript/ai-client/src/chat-client.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,16 @@ export class ChatClient {
160160
input: any
161161
approvalId: string
162162
}) => {
163-
this.events.approvalRequested(
164-
this.currentMessageId || '',
165-
args.toolCallId,
166-
args.toolName,
167-
args.input,
168-
args.approvalId,
169-
)
163+
if (this.currentStreamId) {
164+
this.events.approvalRequested(
165+
this.currentStreamId,
166+
this.currentMessageId || '',
167+
args.toolCallId,
168+
args.toolName,
169+
args.input,
170+
args.approvalId,
171+
)
172+
}
170173
},
171174
},
172175
})
@@ -210,7 +213,10 @@ export class ChatClient {
210213
parts: [],
211214
createdAt: new Date(),
212215
}
213-
this.events.messageAppended(assistantMessage)
216+
this.events.messageAppended(
217+
assistantMessage,
218+
this.currentStreamId || undefined,
219+
)
214220

215221
// Process each chunk
216222
for await (const chunk of source) {

packages/typescript/ai-client/src/events.ts

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export abstract class ChatClientEventEmitter {
1717
*/
1818
protected abstract emitEvent(
1919
eventName: string,
20-
data?: Record<string, any>,
20+
data?: Record<string, unknown>,
2121
): void
2222

2323
/**
@@ -33,14 +33,14 @@ export abstract class ChatClientEventEmitter {
3333
* Emit loading state changed event
3434
*/
3535
loadingChanged(isLoading: boolean): void {
36-
this.emitEvent('client:loading-changed', { isLoading })
36+
this.emitEvent('client:loading:changed', { isLoading })
3737
}
3838

3939
/**
4040
* Emit error state changed event
4141
*/
4242
errorChanged(error: string | null): void {
43-
this.emitEvent('client:error-changed', {
43+
this.emitEvent('client:error:changed', {
4444
error,
4545
})
4646
}
@@ -49,12 +49,8 @@ export abstract class ChatClientEventEmitter {
4949
* Emit text update events (combines processor and client events)
5050
*/
5151
textUpdated(streamId: string, messageId: string, content: string): void {
52-
this.emitEvent('processor:text-updated', {
52+
this.emitEvent('text:chunk:content', {
5353
streamId,
54-
content,
55-
})
56-
57-
this.emitEvent('client:assistant-message-updated', {
5854
messageId,
5955
content,
6056
})
@@ -71,15 +67,8 @@ export abstract class ChatClientEventEmitter {
7167
state: string,
7268
args: string,
7369
): void {
74-
this.emitEvent('processor:tool-call-state-changed', {
70+
this.emitEvent('tools:call:updated', {
7571
streamId,
76-
toolCallId,
77-
toolName,
78-
state,
79-
arguments: args,
80-
})
81-
82-
this.emitEvent('client:tool-call-updated', {
8372
messageId,
8473
toolCallId,
8574
toolName,
@@ -91,22 +80,6 @@ export abstract class ChatClientEventEmitter {
9180
/**
9281
* Emit tool result state change event
9382
*/
94-
toolResultStateChanged(
95-
streamId: string,
96-
toolCallId: string,
97-
content: string,
98-
state: string,
99-
error?: string,
100-
): void {
101-
this.emitEvent('processor:tool-result-state-changed', {
102-
streamId,
103-
toolCallId,
104-
content,
105-
state,
106-
error,
107-
})
108-
}
109-
11083
/**
11184
* Emit thinking update event
11285
*/
@@ -116,7 +89,7 @@ export abstract class ChatClientEventEmitter {
11689
content: string,
11790
delta?: string,
11891
): void {
119-
this.emitEvent('stream:chunk:thinking', {
92+
this.emitEvent('text:chunk:thinking', {
12093
streamId,
12194
messageId,
12295
content,
@@ -128,13 +101,15 @@ export abstract class ChatClientEventEmitter {
128101
* Emit approval requested event
129102
*/
130103
approvalRequested(
104+
streamId: string,
131105
messageId: string,
132106
toolCallId: string,
133107
toolName: string,
134-
input: any,
108+
input: unknown,
135109
approvalId: string,
136110
): void {
137-
this.emitEvent('client:approval-requested', {
111+
this.emitEvent('tools:approval:requested', {
112+
streamId,
138113
messageId,
139114
toolCallId,
140115
toolName,
@@ -146,26 +121,34 @@ export abstract class ChatClientEventEmitter {
146121
/**
147122
* Emit message appended event
148123
*/
149-
messageAppended(uiMessage: UIMessage): void {
150-
const contentPreview = uiMessage.parts
151-
.filter((p) => p.type === 'text')
152-
.map((p) => (p as any).content)
124+
messageAppended(uiMessage: UIMessage, streamId?: string): void {
125+
const content = uiMessage.parts
126+
.filter((part) => part.type === 'text')
127+
.map((part) => part.content)
153128
.join(' ')
154-
.substring(0, 100)
155129

156-
this.emitEvent('client:message-appended', {
130+
this.emitEvent('text:message:created', {
131+
streamId,
157132
messageId: uiMessage.id,
158133
role: uiMessage.role,
159-
contentPreview,
134+
content,
135+
parts: uiMessage.parts,
160136
})
161137
}
162138

163139
/**
164140
* Emit message sent event
165141
*/
166142
messageSent(messageId: string, content: string): void {
167-
this.emitEvent('client:message-sent', {
143+
this.emitEvent('text:message:created', {
144+
messageId,
145+
role: 'user',
146+
content,
147+
})
148+
149+
this.emitEvent('text:message:user', {
168150
messageId,
151+
role: 'user',
169152
content,
170153
})
171154
}
@@ -190,7 +173,7 @@ export abstract class ChatClientEventEmitter {
190173
* Emit messages cleared event
191174
*/
192175
messagesCleared(): void {
193-
this.emitEvent('client:messages-cleared')
176+
this.emitEvent('client:messages:cleared')
194177
}
195178

196179
/**
@@ -199,10 +182,10 @@ export abstract class ChatClientEventEmitter {
199182
toolResultAdded(
200183
toolCallId: string,
201184
toolName: string,
202-
output: any,
185+
output: unknown,
203186
state: string,
204187
): void {
205-
this.emitEvent('tool:result-added', {
188+
this.emitEvent('tools:result:added', {
206189
toolCallId,
207190
toolName,
208191
output,
@@ -218,7 +201,7 @@ export abstract class ChatClientEventEmitter {
218201
toolCallId: string,
219202
approved: boolean,
220203
): void {
221-
this.emitEvent('tool:approval-responded', {
204+
this.emitEvent('tools:approval:responded', {
222205
approvalId,
223206
toolCallId,
224207
approved,
@@ -235,14 +218,19 @@ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
235218
*/
236219
protected emitEvent(eventName: string, data?: Record<string, any>): void {
237220
// For client:* and tool:* events, automatically add clientId and timestamp
238-
if (eventName.startsWith('client:') || eventName.startsWith('tool:')) {
221+
if (
222+
eventName.startsWith('client:') ||
223+
eventName.startsWith('tools:') ||
224+
eventName.startsWith('text:')
225+
) {
239226
aiEventClient.emit(eventName as any, {
240227
...data,
241228
clientId: this.clientId,
229+
source: 'client',
242230
timestamp: Date.now(),
243231
})
244232
} else {
245-
// For other events (e.g., processor:*), just add timestamp
233+
// For other events, just add timestamp
246234
aiEventClient.emit(eventName as any, {
247235
...data,
248236
timestamp: Date.now(),

0 commit comments

Comments
 (0)