@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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.d.mts +40 -0
- package/dist/index.mjs +681 -0
- package/package.json +62 -0
- package/src/config.ts +48 -0
- package/src/index.ts +2 -0
- package/src/middleware.ts +659 -0
- package/src/otel.ts +247 -0
|
@@ -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
|
+
}
|