Skip to content

Commit 2cb0313

Browse files
feat(ai): generation middleware for media observability (#720) (#760)
* refactor media observability into generation middleware * Address CodeRabbit review feedback * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1047e40 commit 2cb0313

26 files changed

Lines changed: 1909 additions & 151 deletions

File tree

.changeset/activity-observers.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/ai': minor
3+
---
4+
5+
Add activity-agnostic observability to the media activities through the unified middleware system (#720). `generateImage`, `generateVideo`, `generateAudio`, `generateSpeech`, and `generateTranscription` now accept a `middleware` option taking `GenerationMiddleware`s — the base, activity-agnostic contract whose lifecycle hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) receive a `GenerationMiddlewareContext`. `ChatMiddleware` is a superset of this base, so a single `otelMiddleware()` value satisfies both and can be passed to `chat()` and any media activity alike. Like chat middleware, hooks are awaited in order and propagate exceptions (a throwing hook surfaces, rather than being silently swallowed).
6+
7+
`otelMiddleware()` (on the existing `@tanstack/ai/middlewares/otel` subpath) now emits one `gen_ai.*` span per media call, tagged with the correct `gen_ai.operation.name` (`image_generation`, `video_generation`, `audio_generation`, `text_to_speech`, `transcription`), reusing the same `gen_ai.usage.*` attribute set as chat — now including `tanstack.ai.usage.units_billed` for unit-billed media. With a `Meter` it records the `gen_ai.client.operation.duration` histogram per activity. An abandoned streaming-video consumer ends the span via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) instead of leaking it. The `GenerationMiddleware` types are exported from the package root; the `otelMiddleware` value stays on the subpath so importing `@tanstack/ai` never requires the optional `@opentelemetry/api` peer.

docs/advanced/otel.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,44 @@ otelMiddleware({
175175
})
176176
```
177177

178+
## Beyond chat: media activities
179+
180+
`otelMiddleware` is not chat-only. The media activities — `generateImage`, `generateVideo`, `generateAudio`, `generateSpeech`, and `generateTranscription` — accept the **same** `otelMiddleware` value on their `middleware` option. Each is a single request → response (or submit → poll for video), so the middleware emits one span per call instead of the chat span tree:
181+
182+
```ts
183+
import { generateImage } from '@tanstack/ai'
184+
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
185+
import { openaiImage } from '@tanstack/ai-openai'
186+
import { trace, metrics } from '@opentelemetry/api'
187+
188+
const otel = otelMiddleware({
189+
tracer: trace.getTracer('my-app'),
190+
meter: metrics.getMeter('my-app'),
191+
})
192+
193+
const result = await generateImage({
194+
adapter: openaiImage('gpt-image-2'),
195+
prompt: 'A serene mountain landscape at sunset',
196+
middleware: [otel],
197+
})
198+
```
199+
200+
The same `otel` value can be passed to `chat()` and to any media activity — its shared lifecycle hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) are authored against the activity-agnostic `GenerationMiddlewareContext`, so the one instance works everywhere.
201+
202+
Each media call produces one `CLIENT` span tagged with the activity's `gen_ai.operation.name`:
203+
204+
| Activity | `gen_ai.operation.name` |
205+
| --- | --- |
206+
| `generateImage` | `image_generation` |
207+
| `generateVideo` | `video_generation` |
208+
| `generateAudio` | `audio_generation` |
209+
| `generateSpeech` | `text_to_speech` |
210+
| `generateTranscription` | `transcription` |
211+
212+
The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including `tanstack.ai.usage.units_billed` for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle; for non-streaming `generateVideo` it covers job submission. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked.
213+
214+
`otelMiddleware` applies the same `spanNameFormatter`, `attributeEnricher`, `onBeforeSpanStart`, and `onSpanEnd` extension points to media spans — the span info is discriminated by `kind`, where media spans report `kind: 'generation'`. For a custom backend, implement the base `GenerationMiddleware` contract directly; its hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) receive the `GenerationMiddlewareContext` and fire for every activity, chat included. The `GenerationMiddleware` types are exported from the package root, while the `otelMiddleware` value lives on the `@tanstack/ai/middlewares/otel` subpath so importing `@tanstack/ai` never requires the optional `@opentelemetry/api` peer.
215+
178216
## Related
179217

180218
- [Middleware](./middleware) — the lifecycle this middleware hooks into

docs/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@
287287
"label": "OpenTelemetry",
288288
"to": "advanced/otel",
289289
"addedAt": "2026-05-08",
290-
"updatedAt": "2026-06-11"
290+
"updatedAt": "2026-06-17"
291291
}
292292
]
293293
},

packages/ai/src/activities/chat/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ class TextEngine<
650650
this.deferredPromises.push(promise)
651651
},
652652
// Provider / adapter info
653+
activity: 'chat',
653654
provider: config.adapter.name,
654655
model: config.params.model,
655656
source: 'server',

packages/ai/src/activities/chat/middleware/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ export interface ChatMiddlewareContext<TContext = unknown> {
8080

8181
// --- Provider / adapter info (immutable for the lifetime of the request) ---
8282

83+
/**
84+
* Which activity this context describes — always `'chat'`. Present so the
85+
* chat context structurally satisfies the base `GenerationMiddlewareContext`,
86+
* letting an observe-only middleware authored against the base (e.g.
87+
* `otelMiddleware`) run on both chat and media activities.
88+
*/
89+
activity: 'chat'
8390
/** Provider name (e.g., 'openai', 'anthropic') */
8491
provider: string
8592
/** Model identifier (e.g., 'gpt-4o') */

packages/ai/src/activities/generateAudio/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
import { aiEventClient } from '@tanstack/ai-event-client'
99
import { streamGenerationResult } from '../stream-generation-result.js'
1010
import { resolveDebugOption } from '../../logger/resolve'
11+
import {
12+
createGenerationContext,
13+
runGenerationError,
14+
runGenerationFinish,
15+
runGenerationStart,
16+
runGenerationUsage,
17+
} from '../middleware'
1118
import type { InternalLogger } from '../../logger/internal-logger'
1219
import type { DebugOption } from '../../logger/types'
20+
import type { GenerationMiddleware } from '../middleware'
1321
import type { AudioAdapter } from './adapter'
1422
import type { AudioGenerationResult, StreamChunk } from '../../types'
1523

@@ -70,6 +78,12 @@ export interface AudioActivityOptions<
7078
* control and/or a custom `Logger`.
7179
*/
7280
debug?: DebugOption
81+
/**
82+
* Observe-only middleware notified on start, usage, success, and error. Pass
83+
* `otelMiddleware()` to emit OpenTelemetry spans, or implement the
84+
* `GenerationMiddleware` contract for a custom backend.
85+
*/
86+
middleware?: Array<GenerationMiddleware>
7387
}
7488

7589
// ===========================
@@ -135,7 +149,13 @@ async function runGenerateAudio<
135149
>(
136150
options: AudioActivityOptions<TAdapter, boolean>,
137151
): Promise<AudioGenerationResult> {
138-
const { adapter, stream: _stream, debug: _debug, ...rest } = options
152+
const {
153+
adapter,
154+
stream: _stream,
155+
debug: _debug,
156+
middleware,
157+
...rest
158+
} = options
139159
const model = adapter.model
140160
const requestId = createId('audio')
141161
const startTime = Date.now()
@@ -145,6 +165,17 @@ async function runGenerateAudio<
145165
(adapter as { name?: string }).name ??
146166
'unknown'
147167

168+
const mwCtx = createGenerationContext({
169+
requestId,
170+
activity: 'audio',
171+
provider: adapter.name,
172+
model,
173+
modelOptions: rest.modelOptions,
174+
createId,
175+
})
176+
177+
await runGenerationStart(middleware, mwCtx)
178+
148179
aiEventClient.emit('audio:request:started', {
149180
requestId,
150181
provider: adapter.name,
@@ -189,6 +220,12 @@ async function runGenerateAudio<
189220
audioDuration: result.audio.duration,
190221
})
191222

223+
if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
224+
await runGenerationFinish(middleware, mwCtx, {
225+
duration: elapsedMs,
226+
usage: result.usage,
227+
})
228+
192229
return result
193230
} catch (error) {
194231
const elapsedMs = Date.now() - startTime
@@ -202,6 +239,10 @@ async function runGenerateAudio<
202239
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
203240
timestamp: Date.now(),
204241
})
242+
await runGenerationError(middleware, mwCtx, {
243+
error,
244+
duration: elapsedMs,
245+
})
205246
logger.errors('generateAudio activity failed', {
206247
error,
207248
source: 'generateAudio',

packages/ai/src/activities/generateImage/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@
88
import { aiEventClient } from '@tanstack/ai-event-client'
99
import { streamGenerationResult } from '../stream-generation-result.js'
1010
import { resolveDebugOption } from '../../logger/resolve'
11+
import {
12+
createGenerationContext,
13+
runGenerationError,
14+
runGenerationFinish,
15+
runGenerationStart,
16+
runGenerationUsage,
17+
} from '../middleware'
1118
import { resolveMediaPrompt } from '../../utilities/media-prompt'
1219
import type { InternalLogger } from '../../logger/internal-logger'
1320
import type { DebugOption } from '../../logger/types'
21+
import type { GenerationMiddleware } from '../middleware'
1422
import type { ImageAdapter } from './adapter'
1523
import type {
1624
ImageGenerationResult,
@@ -123,6 +131,12 @@ export type ImageActivityOptions<
123131
* control and/or a custom `Logger`.
124132
*/
125133
debug?: DebugOption
134+
/**
135+
* Observe-only middleware notified on start, usage, success, and error. Pass
136+
* `otelMiddleware()` to emit OpenTelemetry spans, or implement the
137+
* `GenerationMiddleware` contract for a custom backend.
138+
*/
139+
middleware?: Array<GenerationMiddleware>
126140
} & ({} extends ImageProviderOptionsForModel<TAdapter, TAdapter['model']>
127141
? {
128142
/** Provider-specific options for image generation */ modelOptions?: ImageProviderOptionsForModel<
@@ -228,12 +242,29 @@ async function runGenerateImage<
228242
>(
229243
options: ImageActivityOptions<TAdapter, boolean>,
230244
): Promise<ImageGenerationResult> {
231-
const { adapter, stream: _stream, debug: _debug, ...rest } = options
245+
const {
246+
adapter,
247+
stream: _stream,
248+
debug: _debug,
249+
middleware,
250+
...rest
251+
} = options
232252
const model = adapter.model
233253
const requestId = createId('image')
234254
const startTime = Date.now()
235255
const logger: InternalLogger = resolveDebugOption(options.debug)
236256

257+
const mwCtx = createGenerationContext({
258+
requestId,
259+
activity: 'image',
260+
provider: adapter.name,
261+
model,
262+
modelOptions: rest.modelOptions,
263+
createId,
264+
})
265+
266+
await runGenerationStart(middleware, mwCtx)
267+
237268
// Devtools events carry the flattened prompt text plus media-part counts —
238269
// the wire payload stays `prompt: string` regardless of the prompt shape.
239270
const resolved = resolveMediaPrompt(rest.prompt)
@@ -299,8 +330,18 @@ async function runGenerateImage<
299330
count: result.images.length,
300331
})
301332

333+
if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
334+
await runGenerationFinish(middleware, mwCtx, {
335+
duration,
336+
usage: result.usage,
337+
})
338+
302339
return result
303340
} catch (error) {
341+
await runGenerationError(middleware, mwCtx, {
342+
error,
343+
duration: Date.now() - startTime,
344+
})
304345
logger.errors('generateImage activity failed', {
305346
error,
306347
source: 'generateImage',

packages/ai/src/activities/generateSpeech/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
import { aiEventClient } from '@tanstack/ai-event-client'
99
import { streamGenerationResult } from '../stream-generation-result.js'
1010
import { resolveDebugOption } from '../../logger/resolve'
11+
import {
12+
createGenerationContext,
13+
runGenerationError,
14+
runGenerationFinish,
15+
runGenerationStart,
16+
runGenerationUsage,
17+
} from '../middleware'
1118
import type { InternalLogger } from '../../logger/internal-logger'
1219
import type { DebugOption } from '../../logger/types'
20+
import type { GenerationMiddleware } from '../middleware'
1321
import type { TTSAdapter } from './adapter'
1422
import type { StreamChunk, TTSResult } from '../../types'
1523

@@ -73,6 +81,12 @@ export interface TTSActivityOptions<
7381
* control and/or a custom `Logger`.
7482
*/
7583
debug?: DebugOption
84+
/**
85+
* Observe-only middleware notified on start, usage, success, and error. Pass
86+
* `otelMiddleware()` to emit OpenTelemetry spans, or implement the
87+
* `GenerationMiddleware` contract for a custom backend.
88+
*/
89+
middleware?: Array<GenerationMiddleware>
7690
}
7791

7892
// ===========================
@@ -143,7 +157,13 @@ export function generateSpeech<
143157
async function runGenerateSpeech<
144158
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
145159
>(options: TTSActivityOptions<TAdapter, boolean>): Promise<TTSResult> {
146-
const { adapter, stream: _stream, debug: _debug, ...rest } = options
160+
const {
161+
adapter,
162+
stream: _stream,
163+
debug: _debug,
164+
middleware,
165+
...rest
166+
} = options
147167
const model = adapter.model
148168
const requestId = createId('speech')
149169
const startTime = Date.now()
@@ -153,6 +173,17 @@ async function runGenerateSpeech<
153173
(adapter as { name?: string }).name ??
154174
'unknown'
155175

176+
const mwCtx = createGenerationContext({
177+
requestId,
178+
activity: 'tts',
179+
provider: adapter.name,
180+
model,
181+
modelOptions: rest.modelOptions,
182+
createId,
183+
})
184+
185+
await runGenerationStart(middleware, mwCtx)
186+
156187
aiEventClient.emit('speech:request:started', {
157188
requestId,
158189
provider: adapter.name,
@@ -202,6 +233,12 @@ async function runGenerateSpeech<
202233
contentType: result.contentType,
203234
})
204235

236+
if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
237+
await runGenerationFinish(middleware, mwCtx, {
238+
duration,
239+
usage: result.usage,
240+
})
241+
205242
return result
206243
} catch (error) {
207244
const duration = Date.now() - startTime
@@ -215,6 +252,10 @@ async function runGenerateSpeech<
215252
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
216253
timestamp: Date.now(),
217254
})
255+
await runGenerationError(middleware, mwCtx, {
256+
error,
257+
duration,
258+
})
218259
logger.errors('generateSpeech activity failed', {
219260
error,
220261
source: 'generateSpeech',

0 commit comments

Comments
 (0)