@telemetry-dev/tanstack-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,659 @@
1
+ import {
2
+ type Attributes,
3
+ type Context,
4
+ ROOT_CONTEXT,
5
+ type Span,
6
+ SpanKind,
7
+ SpanStatusCode,
8
+ trace,
9
+ } from "@opentelemetry/api";
10
+ import type {
11
+ AbortInfo,
12
+ AfterToolCallInfo,
13
+ ChatMiddleware,
14
+ ChatMiddlewareConfig,
15
+ ChatMiddlewareContext,
16
+ ErrorInfo,
17
+ FinishInfo,
18
+ TokenUsage,
19
+ ToolCallHookContext,
20
+ ToolPhaseCompleteInfo,
21
+ UsageInfo,
22
+ } from "@tanstack/ai";
23
+
24
+ import { resolveConfig, type TelemetryDevOptions } from "./config.ts";
25
+ import { createEmitter, type EmitterOverrides } from "./otel.ts";
26
+
27
+ function omitUndefined(attributes: Attributes): Attributes {
28
+ const out: Attributes = {};
29
+ for (const key of Object.keys(attributes)) {
30
+ const value = attributes[key];
31
+ if (value !== undefined) {
32
+ out[key] = value;
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function readId(value: unknown): string | null {
39
+ if (typeof value === "string") {
40
+ return value.length > 0 ? value : null;
41
+ }
42
+ if (typeof value === "number" || typeof value === "bigint") {
43
+ return value.toString();
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // Stringify structured content (messages / tool args) for the gen_ai.* string attributes the
49
+ // ingest parses back into JSON. Returns undefined so omitUndefined drops absent content.
50
+ function jsonAttr(value: unknown): string | undefined {
51
+ if (value === undefined) return undefined;
52
+ if (typeof value === "string") return value;
53
+ try {
54
+ return JSON.stringify(value);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function firstNumber(...candidates: unknown[]): number | undefined {
61
+ for (const candidate of candidates) {
62
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
63
+ return candidate;
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ function errorTypeName(err: unknown): string {
70
+ if (err instanceof Error) return err.name || "Error";
71
+ if (err && typeof err === "object" && "name" in err) {
72
+ const n = (err as { name?: unknown }).name;
73
+ if (typeof n === "string" && n.length > 0) return n;
74
+ }
75
+ return "Error";
76
+ }
77
+
78
+ function errorMessage(err: unknown): string {
79
+ if (err instanceof Error) return err.message;
80
+ if (typeof err === "string") return err;
81
+ if (err && typeof err === "object" && "message" in err) {
82
+ const m = (err as { message?: unknown }).message;
83
+ if (typeof m === "string") return m;
84
+ }
85
+ return String(err);
86
+ }
87
+
88
+ const SEVERITY_INFO = 9;
89
+ const SEVERITY_ERROR = 17;
90
+
91
+ // Provider-native spellings of the output-token cap, mirrored from TanStack AI's
92
+ // `utilities/sampling-keys.ts` (not exported from the package root).
93
+ const MAX_TOKENS_KEYS = [
94
+ "max_output_tokens",
95
+ "max_tokens",
96
+ "max_completion_tokens",
97
+ "maxOutputTokens",
98
+ "maxCompletionTokens",
99
+ "maxTokens",
100
+ ] as const;
101
+
102
+ // Sampling options live in opaque provider-native `modelOptions`; pick the first numeric value
103
+ // among the known spellings (including Ollama's nested `options`) for the gen_ai.request.* attrs.
104
+ function samplingAttributes(modelOptions: Record<string, unknown> | undefined): Attributes {
105
+ const sampling = modelOptions ?? {};
106
+ const nested =
107
+ sampling["options"] && typeof sampling["options"] === "object"
108
+ ? (sampling["options"] as Record<string, unknown>)
109
+ : undefined;
110
+ return omitUndefined({
111
+ "gen_ai.request.temperature": firstNumber(sampling["temperature"], nested?.["temperature"]),
112
+ "gen_ai.request.top_p": firstNumber(sampling["top_p"], sampling["topP"], nested?.["top_p"]),
113
+ "gen_ai.request.max_tokens": firstNumber(
114
+ ...MAX_TOKENS_KEYS.map((key) => sampling[key]),
115
+ nested?.["num_predict"],
116
+ ),
117
+ });
118
+ }
119
+
120
+ interface IterationState {
121
+ span: Span;
122
+ otelCtx: Context;
123
+ startedAt: Date;
124
+ structured: boolean;
125
+ usage: TokenUsage | null;
126
+ finishReason: string | null;
127
+ responseModel: string | null;
128
+ outputText: string | null;
129
+ }
130
+
131
+ interface RunState {
132
+ rootSpan: Span;
133
+ rootCtx: Context;
134
+ provider: string;
135
+ requestModel: string;
136
+ responseModel: string | null;
137
+ userId: string | null;
138
+ sessionId: string;
139
+ restMetadata: Record<string, unknown> | undefined;
140
+ rootInput: string | undefined;
141
+ rootSampling: Attributes;
142
+ rootCaptured: boolean;
143
+ iteration: IterationState | null;
144
+ // Raw JSON from the legacy `chat({ outputSchema })` finalization iteration; preferred over
145
+ // onFinish's `info.content` for the root output (finalization never updates accumulatedContent).
146
+ structuredOutput: string | null;
147
+ openTools: Map<string, { span: Span; startedAt: Date }>;
148
+ childSpans: Span[];
149
+ hasToolSpan: boolean;
150
+ iterationMetrics: Array<{
151
+ durationSec: number;
152
+ inputTokens: number | null;
153
+ outputTokens: number | null;
154
+ }>;
155
+ toolMetrics: Array<{ durationSec: number }>;
156
+ }
157
+
158
+ /**
159
+ * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
160
+ * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call, one CLIENT
161
+ * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
162
+ * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
163
+ * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
164
+ */
165
+ export function telemetryDev(
166
+ options?: TelemetryDevOptions,
167
+ overrides?: EmitterOverrides,
168
+ ): ChatMiddleware {
169
+ const config = resolveConfig(options);
170
+
171
+ // Gate on our own config: with no key, the middleware is inert and never throws.
172
+ if (!config.apiKey) {
173
+ return { name: "telemetry-dev" };
174
+ }
175
+
176
+ const emitter = createEmitter(config, overrides);
177
+ const onError = config.onError;
178
+ const states = new WeakMap<ChatMiddlewareContext, RunState>();
179
+
180
+ const closeIteration = (state: RunState): void => {
181
+ const iteration = state.iteration;
182
+ if (!iteration) return;
183
+ const endedAt = new Date();
184
+ const usage = iteration.usage;
185
+ iteration.span.setAttributes(
186
+ omitUndefined({
187
+ "gen_ai.response.model": iteration.responseModel ?? undefined,
188
+ "gen_ai.response.finish_reasons": iteration.finishReason
189
+ ? [iteration.finishReason]
190
+ : undefined,
191
+ "gen_ai.usage.input_tokens": usage?.promptTokens,
192
+ "gen_ai.usage.output_tokens": usage?.completionTokens,
193
+ "gen_ai.usage.cache_read.input_tokens": usage?.promptTokensDetails?.cachedTokens,
194
+ "gen_ai.usage.cache_creation.input_tokens": usage?.promptTokensDetails?.cacheWriteTokens,
195
+ "gen_ai.usage.reasoning.output_tokens": usage?.completionTokensDetails?.reasoningTokens,
196
+ "gen_ai.usage.cost": usage?.cost,
197
+ "gen_ai.output.type": iteration.structured ? "json" : "text",
198
+ "gen_ai.output.messages": jsonAttr(iteration.outputText ?? undefined),
199
+ }),
200
+ );
201
+ iteration.span.end(endedAt);
202
+ state.childSpans.push(iteration.span);
203
+ state.iterationMetrics.push({
204
+ durationSec: Math.max(endedAt.getTime() - iteration.startedAt.getTime(), 0) / 1000,
205
+ inputTokens: usage?.promptTokens ?? null,
206
+ outputTokens: usage?.completionTokens ?? null,
207
+ });
208
+ if (iteration.structured && iteration.outputText !== null) {
209
+ state.structuredOutput = iteration.outputText;
210
+ }
211
+ state.iteration = null;
212
+ };
213
+
214
+ // Root attributes shared by all three terminal hooks. Rolled-up usage is deliberately NOT set
215
+ // as root gen_ai.usage.* attributes: the ingest sums usage across every span of a trace, so a
216
+ // root rollup would double-count tokens/cost. The rollup lands on the generation.summary event.
217
+ const setRootBaseAttributes = (state: RunState): void => {
218
+ state.rootSpan.setAttributes(
219
+ omitUndefined({
220
+ "gen_ai.operation.name": state.hasToolSpan ? "invoke_agent" : "chat",
221
+ "gen_ai.provider.name": state.provider,
222
+ "gen_ai.request.model": state.requestModel,
223
+ "gen_ai.response.model": state.responseModel ?? undefined,
224
+ "gen_ai.conversation.id": state.sessionId,
225
+ "gen_ai.input.messages": state.rootInput,
226
+ "user.id": state.userId ?? undefined,
227
+ ...state.rootSampling,
228
+ }),
229
+ );
230
+ if (state.restMetadata) {
231
+ for (const [key, value] of Object.entries(state.restMetadata)) {
232
+ const attr = typeof value === "string" ? value : jsonAttr(value);
233
+ if (attr !== undefined) {
234
+ state.rootSpan.setAttribute(`td.metadata.${key}`, attr);
235
+ }
236
+ }
237
+ }
238
+ };
239
+
240
+ const addSummaryEvent = (state: RunState, hasError: boolean, message: string): void => {
241
+ const inputPresent = state.iterationMetrics.some((m) => m.inputTokens !== null);
242
+ const outputPresent = state.iterationMetrics.some((m) => m.outputTokens !== null);
243
+ const inputTokens = state.iterationMetrics.reduce((sum, m) => sum + (m.inputTokens ?? 0), 0);
244
+ const outputTokens = state.iterationMetrics.reduce((sum, m) => sum + (m.outputTokens ?? 0), 0);
245
+ state.rootSpan.addEvent(
246
+ "generation.summary",
247
+ omitUndefined({
248
+ "log.severity_number": hasError ? SEVERITY_ERROR : SEVERITY_INFO,
249
+ "log.message": message,
250
+ "gen_ai.usage.input_tokens": inputPresent ? inputTokens : undefined,
251
+ "gen_ai.usage.output_tokens": outputPresent ? outputTokens : undefined,
252
+ }),
253
+ );
254
+ };
255
+
256
+ const recordRunMetrics = (state: RunState): void => {
257
+ const metricBase: Attributes = omitUndefined({
258
+ "gen_ai.provider.name": state.provider,
259
+ "gen_ai.request.model": state.requestModel,
260
+ "gen_ai.response.model": state.responseModel ?? undefined,
261
+ });
262
+ for (const m of state.iterationMetrics) {
263
+ const attrs: Attributes = { ...metricBase, "gen_ai.operation.name": "chat" };
264
+ emitter.recordDuration(m.durationSec, attrs);
265
+ if (m.inputTokens !== null) emitter.recordTokens("input", m.inputTokens, attrs);
266
+ if (m.outputTokens !== null) emitter.recordTokens("output", m.outputTokens, attrs);
267
+ }
268
+ for (const t of state.toolMetrics) {
269
+ emitter.recordDuration(t.durationSec, {
270
+ ...metricBase,
271
+ "gen_ai.operation.name": "execute_tool",
272
+ });
273
+ }
274
+ };
275
+
276
+ // Mark every still-open iteration/tool span failed and end it, so the terminal error/abort
277
+ // hooks never leave dangling spans out of the flushed batch.
278
+ const failOpenSpans = (state: RunState, errType: string, message: string): void => {
279
+ for (const [, entry] of state.openTools) {
280
+ entry.span.setStatus({ code: SpanStatusCode.ERROR, message });
281
+ entry.span.setAttribute("error.type", errType);
282
+ entry.span.end();
283
+ state.childSpans.push(entry.span);
284
+ }
285
+ state.openTools.clear();
286
+ if (state.iteration) {
287
+ state.iteration.span.setStatus({ code: SpanStatusCode.ERROR, message });
288
+ state.iteration.span.setAttribute("error.type", errType);
289
+ closeIteration(state);
290
+ }
291
+ };
292
+
293
+ return {
294
+ name: "telemetry-dev",
295
+
296
+ onStart(ctx) {
297
+ try {
298
+ const rawMetadata = ctx.options?.["metadata"];
299
+ const metadata =
300
+ rawMetadata && typeof rawMetadata === "object"
301
+ ? (rawMetadata as Record<string, unknown>)
302
+ : undefined;
303
+ const userId = readId(metadata?.["userId"]);
304
+ const sessionId = readId(metadata?.["sessionId"]) ?? ctx.threadId;
305
+ let restMetadata: Record<string, unknown> | undefined;
306
+ if (metadata) {
307
+ const rest: Record<string, unknown> = {};
308
+ for (const [key, value] of Object.entries(metadata)) {
309
+ if (key !== "userId" && key !== "sessionId") {
310
+ rest[key] = value;
311
+ }
312
+ }
313
+ restMetadata = Object.keys(rest).length > 0 ? rest : undefined;
314
+ }
315
+
316
+ const rootSpan = emitter.tracer.startSpan("chat", {
317
+ startTime: new Date(),
318
+ kind: SpanKind.INTERNAL,
319
+ });
320
+ states.set(ctx, {
321
+ rootSpan,
322
+ rootCtx: trace.setSpan(ROOT_CONTEXT, rootSpan),
323
+ provider: ctx.provider,
324
+ requestModel: ctx.model,
325
+ responseModel: null,
326
+ userId,
327
+ sessionId,
328
+ restMetadata,
329
+ rootInput: undefined,
330
+ rootSampling: {},
331
+ rootCaptured: false,
332
+ iteration: null,
333
+ structuredOutput: null,
334
+ openTools: new Map(),
335
+ childSpans: [],
336
+ hasToolSpan: false,
337
+ iterationMetrics: [],
338
+ toolMetrics: [],
339
+ });
340
+ } catch (err) {
341
+ onError?.(err);
342
+ }
343
+ },
344
+
345
+ onConfig(ctx, chatConfig: ChatMiddlewareConfig) {
346
+ // Both remaining phases are model-call boundaries: `beforeModel` per agent-loop iteration
347
+ // and `structuredOutput` before the legacy finalization call the engine issues for
348
+ // `chat({ outputSchema })` on adapters without native combined support. The latter needs
349
+ // its own span — otherwise its onUsage would overwrite the last iteration's usage.
350
+ if (ctx.phase !== "beforeModel" && ctx.phase !== "structuredOutput") return undefined;
351
+ try {
352
+ const state = states.get(ctx);
353
+ if (!state) return undefined;
354
+
355
+ // The previous iteration's span stays open through tool execution and onUsage so tool
356
+ // spans nest under it and usage lands on it. Close it just before the next model call.
357
+ closeIteration(state);
358
+
359
+ const inputMessages: Array<{ role: string; content: unknown }> = [];
360
+ for (const prompt of chatConfig.systemPrompts) {
361
+ inputMessages.push({
362
+ role: "system",
363
+ content: typeof prompt === "string" ? prompt : prompt.content,
364
+ });
365
+ }
366
+ for (const message of chatConfig.messages) {
367
+ inputMessages.push({ role: message.role, content: message.content });
368
+ }
369
+ const inputJson = jsonAttr(inputMessages);
370
+ const sampling = samplingAttributes(chatConfig.modelOptions ?? ctx.modelOptions);
371
+ if (!state.rootCaptured) {
372
+ state.rootCaptured = true;
373
+ state.rootInput = inputJson;
374
+ state.rootSampling = sampling;
375
+ }
376
+
377
+ const startedAt = new Date();
378
+ const span = emitter.tracer.startSpan(
379
+ "chat",
380
+ {
381
+ startTime: startedAt,
382
+ kind: SpanKind.CLIENT,
383
+ attributes: omitUndefined({
384
+ "gen_ai.operation.name": "chat",
385
+ "gen_ai.provider.name": ctx.provider,
386
+ "gen_ai.request.model": ctx.model,
387
+ "gen_ai.conversation.id": state.sessionId,
388
+ "gen_ai.input.messages": inputJson,
389
+ ...sampling,
390
+ }),
391
+ },
392
+ state.rootCtx,
393
+ );
394
+ state.iteration = {
395
+ span,
396
+ otelCtx: trace.setSpan(state.rootCtx, span),
397
+ startedAt,
398
+ usage: null,
399
+ finishReason: null,
400
+ responseModel: null,
401
+ outputText: null,
402
+ structured: ctx.phase === "structuredOutput",
403
+ };
404
+ } catch (err) {
405
+ onError?.(err);
406
+ }
407
+ return undefined;
408
+ },
409
+
410
+ onChunk(ctx, chunk) {
411
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "CUSTOM") return undefined;
412
+ try {
413
+ const state = states.get(ctx);
414
+ const iteration = state?.iteration;
415
+ if (!state || !iteration) return undefined;
416
+ if (chunk.type === "CUSTOM") {
417
+ // The finalization stream reports its JSON via this event; `ctx.accumulatedContent`
418
+ // still holds the agent loop's text, so this is the structured span's only output.
419
+ if (iteration.structured && chunk.name === "structured-output.complete") {
420
+ const raw = (chunk.value as { raw?: unknown } | null | undefined)?.raw;
421
+ if (typeof raw === "string") iteration.outputText = raw;
422
+ }
423
+ return undefined;
424
+ }
425
+ iteration.finishReason = chunk.finishReason ?? null;
426
+ if (chunk.model) {
427
+ iteration.responseModel = chunk.model;
428
+ state.responseModel = chunk.model;
429
+ }
430
+ if (chunk.usage) iteration.usage = chunk.usage;
431
+ if (!iteration.structured) {
432
+ iteration.outputText = ctx.accumulatedContent.length > 0 ? ctx.accumulatedContent : null;
433
+ }
434
+ } catch (err) {
435
+ onError?.(err);
436
+ }
437
+ return undefined;
438
+ },
439
+
440
+ onUsage(ctx, usage: UsageInfo) {
441
+ try {
442
+ const state = states.get(ctx);
443
+ if (state?.iteration) {
444
+ state.iteration.usage = usage;
445
+ }
446
+ } catch (err) {
447
+ onError?.(err);
448
+ }
449
+ },
450
+
451
+ onBeforeToolCall(ctx, hookCtx: ToolCallHookContext) {
452
+ try {
453
+ const state = states.get(ctx);
454
+ if (!state) return undefined;
455
+ const startedAt = new Date();
456
+ const span = emitter.tracer.startSpan(
457
+ "execute_tool",
458
+ {
459
+ startTime: startedAt,
460
+ kind: SpanKind.INTERNAL,
461
+ attributes: omitUndefined({
462
+ "gen_ai.operation.name": "execute_tool",
463
+ "gen_ai.tool.name": hookCtx.toolName,
464
+ "gen_ai.tool.call.id": hookCtx.toolCallId,
465
+ "gen_ai.conversation.id": state.sessionId,
466
+ "gen_ai.tool.call.arguments": jsonAttr(hookCtx.args ?? null),
467
+ }),
468
+ },
469
+ state.iteration?.otelCtx ?? state.rootCtx,
470
+ );
471
+ state.openTools.set(hookCtx.toolCallId, { span, startedAt });
472
+ state.hasToolSpan = true;
473
+ } catch (err) {
474
+ onError?.(err);
475
+ }
476
+ return undefined;
477
+ },
478
+
479
+ onAfterToolCall(ctx, info: AfterToolCallInfo) {
480
+ try {
481
+ const state = states.get(ctx);
482
+ const entry = state?.openTools.get(info.toolCallId);
483
+ if (!state || !entry) return;
484
+ state.openTools.delete(info.toolCallId);
485
+ const { span } = entry;
486
+
487
+ if (info.ok) {
488
+ const result = jsonAttr(info.result ?? null);
489
+ if (result !== undefined) {
490
+ span.setAttribute("gen_ai.tool.call.result", result);
491
+ }
492
+ } else {
493
+ const message = errorMessage(info.error);
494
+ const errType = info.error instanceof Error ? info.error.name : "tool_error";
495
+ span.setStatus({ code: SpanStatusCode.ERROR });
496
+ span.setAttribute("error.type", errType);
497
+ span.addEvent("exception", {
498
+ "exception.type": errType,
499
+ "exception.message": message,
500
+ "log.severity_number": SEVERITY_ERROR,
501
+ });
502
+ }
503
+
504
+ span.end();
505
+ state.childSpans.push(span);
506
+ state.toolMetrics.push({ durationSec: Math.max(info.duration, 0) / 1000 });
507
+ } catch (err) {
508
+ onError?.(err);
509
+ }
510
+ },
511
+
512
+ async onToolPhaseComplete(ctx, info: ToolPhaseCompleteInfo) {
513
+ // Ordinary tool phases finalize through the terminal hooks; only the wait path needs us.
514
+ if (info.needsApproval.length === 0 && info.needsClientExecution.length === 0) return;
515
+ try {
516
+ const state = states.get(ctx);
517
+ if (!state) return;
518
+ states.delete(ctx);
519
+
520
+ // The engine parks in toolPhase "wait" (user approval / client-side tool execution) and
521
+ // never fires onFinish/onError/onAbort for this invocation — the resumed call is a new
522
+ // chat() with a fresh ctx. Treat this as a terminal hook: close everything and flush,
523
+ // otherwise the whole run's spans leak unexported.
524
+ for (const [, entry] of state.openTools) {
525
+ entry.span.end();
526
+ state.childSpans.push(entry.span);
527
+ }
528
+ state.openTools.clear();
529
+ const finishReason = state.iteration?.finishReason ?? "tool_calls";
530
+ closeIteration(state);
531
+
532
+ // The model requested tools even when none executed server-side (approval-gated and
533
+ // client tools never reach onBeforeToolCall), so this run is an agent invocation.
534
+ state.hasToolSpan = true;
535
+ setRootBaseAttributes(state);
536
+ state.rootSpan.setAttributes(
537
+ omitUndefined({
538
+ "gen_ai.output.messages": jsonAttr(
539
+ ctx.accumulatedContent.length > 0 ? ctx.accumulatedContent : undefined,
540
+ ),
541
+ "gen_ai.response.finish_reasons": [finishReason],
542
+ }),
543
+ );
544
+
545
+ const waitingTools = [...info.needsApproval, ...info.needsClientExecution]
546
+ .map((t) => t.toolName)
547
+ .join(", ");
548
+ addSummaryEvent(state, false, `Generation paused awaiting tools (${waitingTools})`);
549
+
550
+ state.rootSpan.end();
551
+ recordRunMetrics(state);
552
+ await emitter.flush([state.rootSpan, ...state.childSpans]);
553
+ } catch (err) {
554
+ onError?.(err);
555
+ }
556
+ },
557
+
558
+ async onFinish(ctx, info: FinishInfo) {
559
+ try {
560
+ const state = states.get(ctx);
561
+ if (!state) return;
562
+ states.delete(ctx);
563
+
564
+ // Close any tool spans that never received onAfterToolCall before the iteration span, so
565
+ // the hierarchy ends depth-first and nothing dangles out of the flushed batch.
566
+ for (const [, entry] of state.openTools) {
567
+ entry.span.end();
568
+ state.childSpans.push(entry.span);
569
+ }
570
+ state.openTools.clear();
571
+ closeIteration(state);
572
+
573
+ setRootBaseAttributes(state);
574
+ state.rootSpan.setAttributes(
575
+ omitUndefined({
576
+ "gen_ai.output.messages": jsonAttr(state.structuredOutput ?? info.content),
577
+ "gen_ai.response.finish_reasons": info.finishReason ? [info.finishReason] : undefined,
578
+ }),
579
+ );
580
+
581
+ const finishReason = info.finishReason ?? "unknown";
582
+ const inputTokens = state.iterationMetrics.reduce(
583
+ (sum, m) => sum + (m.inputTokens ?? 0),
584
+ 0,
585
+ );
586
+ const outputTokens = state.iterationMetrics.reduce(
587
+ (sum, m) => sum + (m.outputTokens ?? 0),
588
+ 0,
589
+ );
590
+ const tokenParts: string[] = [];
591
+ if (state.iterationMetrics.some((m) => m.inputTokens !== null)) {
592
+ tokenParts.push(`${inputTokens} in`);
593
+ }
594
+ if (state.iterationMetrics.some((m) => m.outputTokens !== null)) {
595
+ tokenParts.push(`${outputTokens} out`);
596
+ }
597
+ const tokenText = tokenParts.length > 0 ? `: ${tokenParts.join(" / ")} tokens` : "";
598
+ addSummaryEvent(state, false, `Generation completed (${finishReason})${tokenText}`);
599
+
600
+ state.rootSpan.end();
601
+ recordRunMetrics(state);
602
+ await emitter.flush([state.rootSpan, ...state.childSpans]);
603
+ } catch (err) {
604
+ onError?.(err);
605
+ }
606
+ },
607
+
608
+ async onError(ctx, info: ErrorInfo) {
609
+ try {
610
+ const state = states.get(ctx);
611
+ if (!state) return;
612
+ states.delete(ctx);
613
+
614
+ const errType = errorTypeName(info.error);
615
+ const message = errorMessage(info.error);
616
+ failOpenSpans(state, errType, message);
617
+
618
+ setRootBaseAttributes(state);
619
+ state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message });
620
+ state.rootSpan.setAttribute("error.type", errType);
621
+ state.rootSpan.addEvent("exception", {
622
+ "exception.type": errType,
623
+ "exception.message": message,
624
+ "log.severity_number": SEVERITY_ERROR,
625
+ });
626
+ addSummaryEvent(state, true, `Generation failed (${errType})`);
627
+
628
+ state.rootSpan.end();
629
+ recordRunMetrics(state);
630
+ await emitter.flush([state.rootSpan, ...state.childSpans]);
631
+ } catch (err) {
632
+ onError?.(err);
633
+ }
634
+ },
635
+
636
+ async onAbort(ctx, info: AbortInfo) {
637
+ try {
638
+ const state = states.get(ctx);
639
+ if (!state) return;
640
+ states.delete(ctx);
641
+
642
+ const message = info.reason ?? "cancelled";
643
+ failOpenSpans(state, "cancelled", message);
644
+
645
+ setRootBaseAttributes(state);
646
+ state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message });
647
+ state.rootSpan.setAttribute("error.type", "cancelled");
648
+ state.rootSpan.setAttribute("gen_ai.response.finish_reasons", ["cancelled"]);
649
+ addSummaryEvent(state, true, `Generation cancelled (${message})`);
650
+
651
+ state.rootSpan.end();
652
+ recordRunMetrics(state);
653
+ await emitter.flush([state.rootSpan, ...state.childSpans]);
654
+ } catch (err) {
655
+ onError?.(err);
656
+ }
657
+ },
658
+ };
659
+ }