@tanstack/ai 0.14.0 → 0.16.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/dist/esm/activities/chat/adapter.d.ts +6 -3
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.js +61 -9
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +26 -3
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/message-updaters.d.ts +4 -2
- package/dist/esm/activities/chat/stream/message-updaters.js +10 -4
- package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +21 -5
- package/dist/esm/activities/chat/stream/processor.js +119 -19
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/stream/types.d.ts +9 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +3 -4
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/middlewares/otel.d.ts +75 -0
- package/dist/esm/middlewares/otel.js +624 -0
- package/dist/esm/middlewares/otel.js.map +1 -0
- package/dist/esm/types.d.ts +23 -7
- package/package.json +15 -2
- package/src/activities/chat/adapter.ts +8 -2
- package/src/activities/chat/index.ts +74 -11
- package/src/activities/chat/messages.ts +29 -1
- package/src/activities/chat/stream/message-updaters.ts +18 -3
- package/src/activities/chat/stream/processor.ts +152 -17
- package/src/activities/chat/stream/types.ts +9 -1
- package/src/activities/chat/tools/tool-calls.ts +4 -4
- package/src/middlewares/index.ts +5 -0
- package/src/middlewares/otel.ts +861 -0
- package/src/types.ts +20 -7
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AttributeValue, Meter, Span, SpanOptions, Tracer } from '@opentelemetry/api';
|
|
2
|
+
import { ChatMiddleware, ChatMiddlewareContext } from '../activities/chat/middleware/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Scope (role) of an OTel span emitted by this middleware.
|
|
5
|
+
*
|
|
6
|
+
* - `chat` — the root span for a single `chat()` call
|
|
7
|
+
* - `iteration` — one per agent-loop iteration (one model call)
|
|
8
|
+
* - `tool` — one per tool execution inside an iteration
|
|
9
|
+
*/
|
|
10
|
+
export type OtelSpanScope = 'chat' | 'iteration' | 'tool';
|
|
11
|
+
/**
|
|
12
|
+
* Alias retained for backwards compatibility. Prefer {@link OtelSpanScope}.
|
|
13
|
+
*
|
|
14
|
+
* @deprecated Use `OtelSpanScope` instead — the name shadows OTel's built-in
|
|
15
|
+
* `SpanKind` which is also imported by integrations of this middleware.
|
|
16
|
+
*/
|
|
17
|
+
export type OtelSpanKind = OtelSpanScope;
|
|
18
|
+
/**
|
|
19
|
+
* Span metadata passed to `spanNameFormatter`, `attributeEnricher`,
|
|
20
|
+
* `onBeforeSpanStart`, and `onSpanEnd`. Discriminated by `kind` so that
|
|
21
|
+
* tool-only fields narrow automatically inside callback bodies.
|
|
22
|
+
*/
|
|
23
|
+
export type OtelSpanInfo<TScope extends OtelSpanScope = OtelSpanScope> = TScope extends 'chat' ? {
|
|
24
|
+
kind: 'chat';
|
|
25
|
+
ctx: ChatMiddlewareContext;
|
|
26
|
+
} : TScope extends 'iteration' ? {
|
|
27
|
+
kind: 'iteration';
|
|
28
|
+
ctx: ChatMiddlewareContext;
|
|
29
|
+
iteration: number;
|
|
30
|
+
} : TScope extends 'tool' ? {
|
|
31
|
+
kind: 'tool';
|
|
32
|
+
ctx: ChatMiddlewareContext;
|
|
33
|
+
iteration: number;
|
|
34
|
+
toolName: string;
|
|
35
|
+
toolCallId: string;
|
|
36
|
+
} : never;
|
|
37
|
+
export interface OtelMiddlewareOptions {
|
|
38
|
+
/** OTel `Tracer` used to start root, iteration, and tool spans. */
|
|
39
|
+
tracer: Tracer;
|
|
40
|
+
/**
|
|
41
|
+
* Optional OTel `Meter`. When provided, the middleware records
|
|
42
|
+
* `gen_ai.client.operation.duration` and `gen_ai.client.token.usage`
|
|
43
|
+
* histograms. Omit to disable metrics without disabling tracing.
|
|
44
|
+
*/
|
|
45
|
+
meter?: Meter;
|
|
46
|
+
/**
|
|
47
|
+
* When `true`, prompt and completion content is attached to iteration spans
|
|
48
|
+
* as `gen_ai.*.message` / `gen_ai.choice` events. Defaults to `false` so
|
|
49
|
+
* that PII never lands on a span by accident.
|
|
50
|
+
*/
|
|
51
|
+
captureContent?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Invoked on every captured content string before it lands on a span.
|
|
54
|
+
* Return a redacted version. If this function throws, the middleware emits
|
|
55
|
+
* the literal sentinel `"[redaction_failed]"` instead of the original text
|
|
56
|
+
* — it never falls back to raw content.
|
|
57
|
+
*/
|
|
58
|
+
redact?: (text: string) => string;
|
|
59
|
+
/**
|
|
60
|
+
* Maximum characters kept in the per-iteration assistant text buffer used
|
|
61
|
+
* to emit `gen_ai.choice` events. Extra characters are truncated with a
|
|
62
|
+
* trailing `"…"` marker. Defaults to 100 000. Set to `0` to disable the
|
|
63
|
+
* cap. Exporters typically truncate long attribute values anyway.
|
|
64
|
+
*/
|
|
65
|
+
maxContentLength?: number;
|
|
66
|
+
/** Override the default span name for each `kind`. */
|
|
67
|
+
spanNameFormatter?: (info: OtelSpanInfo) => string;
|
|
68
|
+
/** Add extra attributes to each span. */
|
|
69
|
+
attributeEnricher?: (info: OtelSpanInfo) => Record<string, AttributeValue>;
|
|
70
|
+
/** Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. */
|
|
71
|
+
onBeforeSpanStart?: (info: OtelSpanInfo, options: SpanOptions) => SpanOptions;
|
|
72
|
+
/** Fires just before every `span.end()`. */
|
|
73
|
+
onSpanEnd?: (info: OtelSpanInfo, span: Span) => void;
|
|
74
|
+
}
|
|
75
|
+
export declare function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware;
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { SpanStatusCode, SpanKind, trace, context } from "@opentelemetry/api";
|
|
2
|
+
const stateByCtx = /* @__PURE__ */ new WeakMap();
|
|
3
|
+
const DEFAULT_MAX_CONTENT_LENGTH = 1e5;
|
|
4
|
+
const REDACTION_FAILED_SENTINEL = "[redaction_failed]";
|
|
5
|
+
function serializeContent(content) {
|
|
6
|
+
if (typeof content === "string") return content;
|
|
7
|
+
if (!Array.isArray(content)) return "";
|
|
8
|
+
const parts = [];
|
|
9
|
+
for (const part of content) {
|
|
10
|
+
if (!part || typeof part !== "object") continue;
|
|
11
|
+
const type = part.type;
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "text":
|
|
14
|
+
parts.push(
|
|
15
|
+
(part.text ?? part.content ?? "").toString()
|
|
16
|
+
);
|
|
17
|
+
break;
|
|
18
|
+
case "image":
|
|
19
|
+
parts.push("[image]");
|
|
20
|
+
break;
|
|
21
|
+
case "audio":
|
|
22
|
+
parts.push("[audio]");
|
|
23
|
+
break;
|
|
24
|
+
case "video":
|
|
25
|
+
parts.push("[video]");
|
|
26
|
+
break;
|
|
27
|
+
case "document":
|
|
28
|
+
parts.push("[document]");
|
|
29
|
+
break;
|
|
30
|
+
default:
|
|
31
|
+
parts.push(`[${type ?? "unknown"}]`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return parts.join(" ");
|
|
35
|
+
}
|
|
36
|
+
function messageEventName(role) {
|
|
37
|
+
switch (role) {
|
|
38
|
+
case "user":
|
|
39
|
+
return "gen_ai.user.message";
|
|
40
|
+
case "assistant":
|
|
41
|
+
return "gen_ai.assistant.message";
|
|
42
|
+
case "tool":
|
|
43
|
+
return "gen_ai.tool.message";
|
|
44
|
+
case "system":
|
|
45
|
+
return "gen_ai.system.message";
|
|
46
|
+
default:
|
|
47
|
+
return `gen_ai.${role}.message`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function errorMessage(err) {
|
|
51
|
+
if (err instanceof Error) return err.message;
|
|
52
|
+
if (typeof err === "string") return err;
|
|
53
|
+
if (err && typeof err === "object" && "message" in err) {
|
|
54
|
+
const m = err.message;
|
|
55
|
+
if (typeof m === "string") return m;
|
|
56
|
+
}
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
function errorTypeName(err) {
|
|
60
|
+
if (err instanceof Error) return err.name || "Error";
|
|
61
|
+
if (err && typeof err === "object" && "name" in err) {
|
|
62
|
+
const n = err.name;
|
|
63
|
+
if (typeof n === "string") return n;
|
|
64
|
+
}
|
|
65
|
+
return "Error";
|
|
66
|
+
}
|
|
67
|
+
function safeCall(label, fn) {
|
|
68
|
+
try {
|
|
69
|
+
return fn();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn(`[otelMiddleware] ${label} failed`, err);
|
|
72
|
+
return void 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function otelMiddleware(options) {
|
|
76
|
+
const {
|
|
77
|
+
tracer,
|
|
78
|
+
meter,
|
|
79
|
+
captureContent = false,
|
|
80
|
+
redact = (s) => s,
|
|
81
|
+
maxContentLength = DEFAULT_MAX_CONTENT_LENGTH,
|
|
82
|
+
spanNameFormatter,
|
|
83
|
+
attributeEnricher,
|
|
84
|
+
onBeforeSpanStart,
|
|
85
|
+
onSpanEnd
|
|
86
|
+
} = options;
|
|
87
|
+
const durationHistogram = meter?.createHistogram(
|
|
88
|
+
"gen_ai.client.operation.duration",
|
|
89
|
+
{
|
|
90
|
+
description: "GenAI client operation duration",
|
|
91
|
+
unit: "s"
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
const tokenHistogram = meter?.createHistogram("gen_ai.client.token.usage", {
|
|
95
|
+
description: "GenAI client token usage",
|
|
96
|
+
unit: "{token}"
|
|
97
|
+
});
|
|
98
|
+
const redactContent = (text) => {
|
|
99
|
+
try {
|
|
100
|
+
return redact(text);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn("[otelMiddleware] otel.redact failed", err);
|
|
103
|
+
return REDACTION_FAILED_SENTINEL;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const appendAssistantText = (state, delta) => {
|
|
107
|
+
if (maxContentLength > 0) {
|
|
108
|
+
if (state.assistantTextBufferTruncated) return;
|
|
109
|
+
const remaining = maxContentLength - state.assistantTextBuffer.length;
|
|
110
|
+
if (remaining <= 0) {
|
|
111
|
+
state.assistantTextBufferTruncated = true;
|
|
112
|
+
state.assistantTextBuffer += "…";
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (delta.length > remaining) {
|
|
116
|
+
state.assistantTextBuffer += delta.slice(0, remaining) + "…";
|
|
117
|
+
state.assistantTextBufferTruncated = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
state.assistantTextBuffer += delta;
|
|
122
|
+
};
|
|
123
|
+
const closeIterationSpan = (state, ctx) => {
|
|
124
|
+
if (!state.currentIterationSpan) return;
|
|
125
|
+
const span = state.currentIterationSpan;
|
|
126
|
+
const iteration = state.iterationCount - 1;
|
|
127
|
+
safeCall(
|
|
128
|
+
"otel.onSpanEnd",
|
|
129
|
+
() => onSpanEnd?.(
|
|
130
|
+
{ kind: "iteration", ctx, iteration },
|
|
131
|
+
span
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
span.end();
|
|
135
|
+
state.currentIterationSpan = null;
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
name: "otel",
|
|
139
|
+
onStart(ctx) {
|
|
140
|
+
safeCall("otel.onStart", () => {
|
|
141
|
+
const info = { kind: "chat", ctx };
|
|
142
|
+
const name = safeCall("otel.spanNameFormatter", () => spanNameFormatter?.(info)) ?? `chat ${ctx.model}`;
|
|
143
|
+
const baseOptions = {
|
|
144
|
+
kind: SpanKind.INTERNAL,
|
|
145
|
+
attributes: {
|
|
146
|
+
"gen_ai.system": ctx.provider,
|
|
147
|
+
"gen_ai.request.model": ctx.model
|
|
148
|
+
// NOTE: `gen_ai.operation.name` is deliberately NOT set on the
|
|
149
|
+
// root span. The root represents a `chat()` invocation that may
|
|
150
|
+
// span multiple model calls; only iteration spans correspond to
|
|
151
|
+
// a single chat operation. Backends that map `operation.name=chat`
|
|
152
|
+
// to a "generation" event (e.g. PostHog LLM Analytics) would
|
|
153
|
+
// otherwise emit a duplicate generation for the wrapper span.
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const spanOptions = safeCall(
|
|
157
|
+
"otel.onBeforeSpanStart",
|
|
158
|
+
() => onBeforeSpanStart?.(info, baseOptions)
|
|
159
|
+
) ?? baseOptions;
|
|
160
|
+
const rootSpan = tracer.startSpan(name, spanOptions);
|
|
161
|
+
const enriched = safeCall(
|
|
162
|
+
"otel.attributeEnricher",
|
|
163
|
+
() => attributeEnricher?.(info)
|
|
164
|
+
);
|
|
165
|
+
if (enriched) rootSpan.setAttributes(enriched);
|
|
166
|
+
stateByCtx.set(ctx, {
|
|
167
|
+
rootSpan,
|
|
168
|
+
currentIterationSpan: null,
|
|
169
|
+
toolSpans: /* @__PURE__ */ new Map(),
|
|
170
|
+
iterationCount: 0,
|
|
171
|
+
assistantTextBuffer: "",
|
|
172
|
+
assistantTextBufferTruncated: false,
|
|
173
|
+
startTime: Date.now()
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
onConfig(ctx, config) {
|
|
178
|
+
if (ctx.phase !== "beforeModel") return;
|
|
179
|
+
safeCall("otel.onConfig", () => {
|
|
180
|
+
const state = stateByCtx.get(ctx);
|
|
181
|
+
if (!state) return;
|
|
182
|
+
closeIterationSpan(state, ctx);
|
|
183
|
+
const info = {
|
|
184
|
+
kind: "iteration",
|
|
185
|
+
ctx,
|
|
186
|
+
iteration: ctx.iteration
|
|
187
|
+
};
|
|
188
|
+
const name = safeCall("otel.spanNameFormatter", () => spanNameFormatter?.(info)) ?? `chat ${ctx.model} #${ctx.iteration}`;
|
|
189
|
+
const baseAttrs = {
|
|
190
|
+
"gen_ai.system": ctx.provider,
|
|
191
|
+
"gen_ai.operation.name": "chat",
|
|
192
|
+
"gen_ai.request.model": ctx.model,
|
|
193
|
+
"tanstack.ai.iteration": ctx.iteration
|
|
194
|
+
};
|
|
195
|
+
if (config.temperature !== void 0)
|
|
196
|
+
baseAttrs["gen_ai.request.temperature"] = config.temperature;
|
|
197
|
+
if (config.topP !== void 0)
|
|
198
|
+
baseAttrs["gen_ai.request.top_p"] = config.topP;
|
|
199
|
+
if (config.maxTokens !== void 0)
|
|
200
|
+
baseAttrs["gen_ai.request.max_tokens"] = config.maxTokens;
|
|
201
|
+
const baseOptions = {
|
|
202
|
+
kind: SpanKind.CLIENT,
|
|
203
|
+
attributes: baseAttrs
|
|
204
|
+
};
|
|
205
|
+
const spanOptions = safeCall(
|
|
206
|
+
"otel.onBeforeSpanStart",
|
|
207
|
+
() => onBeforeSpanStart?.(info, baseOptions)
|
|
208
|
+
) ?? baseOptions;
|
|
209
|
+
const parentCtx = trace.setSpan(
|
|
210
|
+
context.active(),
|
|
211
|
+
state.rootSpan
|
|
212
|
+
);
|
|
213
|
+
let iterSpan;
|
|
214
|
+
context.with(parentCtx, () => {
|
|
215
|
+
iterSpan = tracer.startSpan(name, spanOptions, parentCtx);
|
|
216
|
+
});
|
|
217
|
+
const enriched = safeCall(
|
|
218
|
+
"otel.attributeEnricher",
|
|
219
|
+
() => attributeEnricher?.(info)
|
|
220
|
+
);
|
|
221
|
+
if (enriched) iterSpan.setAttributes(enriched);
|
|
222
|
+
state.currentIterationSpan = iterSpan;
|
|
223
|
+
state.assistantTextBuffer = "";
|
|
224
|
+
state.assistantTextBufferTruncated = false;
|
|
225
|
+
if (captureContent) {
|
|
226
|
+
for (const sys of config.systemPrompts) {
|
|
227
|
+
iterSpan.addEvent("gen_ai.system.message", {
|
|
228
|
+
content: redactContent(sys)
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
for (const m of config.messages) {
|
|
232
|
+
const body = serializeContent(m.content);
|
|
233
|
+
if (body.length === 0) continue;
|
|
234
|
+
iterSpan.addEvent(messageEventName(m.role), {
|
|
235
|
+
content: redactContent(body)
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const inputMessages = [];
|
|
239
|
+
for (const sys of config.systemPrompts) {
|
|
240
|
+
inputMessages.push({
|
|
241
|
+
role: "system",
|
|
242
|
+
content: redactContent(sys)
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
for (const m of config.messages) {
|
|
246
|
+
const body = serializeContent(m.content);
|
|
247
|
+
if (body.length === 0) continue;
|
|
248
|
+
inputMessages.push({
|
|
249
|
+
role: m.role,
|
|
250
|
+
content: redactContent(body)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (inputMessages.length > 0) {
|
|
254
|
+
const inputJson = JSON.stringify(inputMessages);
|
|
255
|
+
iterSpan.setAttribute("gen_ai.input.messages", inputJson);
|
|
256
|
+
iterSpan.setAttribute("langfuse.observation.input", inputJson);
|
|
257
|
+
if (state.iterationCount === 0) {
|
|
258
|
+
state.rootSpan.setAttribute(
|
|
259
|
+
"langfuse.observation.input",
|
|
260
|
+
inputJson
|
|
261
|
+
);
|
|
262
|
+
state.rootSpan.setAttribute("langfuse.trace.input", inputJson);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
state.iterationCount += 1;
|
|
267
|
+
});
|
|
268
|
+
return void 0;
|
|
269
|
+
},
|
|
270
|
+
onChunk(ctx, chunk) {
|
|
271
|
+
safeCall("otel.onChunk", () => {
|
|
272
|
+
const state = stateByCtx.get(ctx);
|
|
273
|
+
if (!state) return;
|
|
274
|
+
if (captureContent && chunk.type === "TEXT_MESSAGE_CONTENT") {
|
|
275
|
+
appendAssistantText(state, chunk.delta);
|
|
276
|
+
}
|
|
277
|
+
if (chunk.type !== "RUN_FINISHED") return;
|
|
278
|
+
const span = state.currentIterationSpan;
|
|
279
|
+
if (!span) return;
|
|
280
|
+
if (chunk.finishReason) {
|
|
281
|
+
span.setAttribute("gen_ai.response.finish_reasons", [
|
|
282
|
+
chunk.finishReason
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
if (chunk.model) span.setAttribute("gen_ai.response.model", chunk.model);
|
|
286
|
+
if (chunk.usage) {
|
|
287
|
+
span.setAttributes({
|
|
288
|
+
"gen_ai.usage.input_tokens": chunk.usage.promptTokens,
|
|
289
|
+
"gen_ai.usage.output_tokens": chunk.usage.completionTokens
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (captureContent && state.assistantTextBuffer.length > 0) {
|
|
293
|
+
const completion = redactContent(state.assistantTextBuffer);
|
|
294
|
+
const outputJson = JSON.stringify([
|
|
295
|
+
{ role: "assistant", content: completion }
|
|
296
|
+
]);
|
|
297
|
+
span.addEvent("gen_ai.choice", { content: completion });
|
|
298
|
+
span.setAttribute("gen_ai.output.messages", outputJson);
|
|
299
|
+
span.setAttribute("langfuse.observation.output", outputJson);
|
|
300
|
+
state.rootSpan.setAttribute("langfuse.observation.output", outputJson);
|
|
301
|
+
state.rootSpan.setAttribute("langfuse.trace.output", outputJson);
|
|
302
|
+
state.assistantTextBuffer = "";
|
|
303
|
+
state.assistantTextBufferTruncated = false;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return void 0;
|
|
307
|
+
},
|
|
308
|
+
onUsage(ctx, usage) {
|
|
309
|
+
safeCall("otel.onUsage", () => {
|
|
310
|
+
const state = stateByCtx.get(ctx);
|
|
311
|
+
if (!state) return;
|
|
312
|
+
if (tokenHistogram) {
|
|
313
|
+
const metricAttrs = {
|
|
314
|
+
"gen_ai.system": ctx.provider,
|
|
315
|
+
"gen_ai.operation.name": "chat",
|
|
316
|
+
"gen_ai.request.model": ctx.model
|
|
317
|
+
};
|
|
318
|
+
tokenHistogram.record(usage.promptTokens, {
|
|
319
|
+
...metricAttrs,
|
|
320
|
+
"gen_ai.token.type": "input"
|
|
321
|
+
});
|
|
322
|
+
tokenHistogram.record(usage.completionTokens, {
|
|
323
|
+
...metricAttrs,
|
|
324
|
+
"gen_ai.token.type": "output"
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const span = state.currentIterationSpan ?? state.rootSpan;
|
|
328
|
+
span.setAttributes({
|
|
329
|
+
"gen_ai.usage.input_tokens": usage.promptTokens,
|
|
330
|
+
"gen_ai.usage.output_tokens": usage.completionTokens
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
onBeforeToolCall(ctx, hookCtx) {
|
|
335
|
+
safeCall("otel.onBeforeToolCall", () => {
|
|
336
|
+
const state = stateByCtx.get(ctx);
|
|
337
|
+
if (!state) return;
|
|
338
|
+
const parent = state.currentIterationSpan ?? state.rootSpan;
|
|
339
|
+
const info = {
|
|
340
|
+
kind: "tool",
|
|
341
|
+
ctx,
|
|
342
|
+
toolName: hookCtx.toolName,
|
|
343
|
+
toolCallId: hookCtx.toolCallId,
|
|
344
|
+
iteration: state.iterationCount - 1
|
|
345
|
+
};
|
|
346
|
+
const name = safeCall("otel.spanNameFormatter", () => spanNameFormatter?.(info)) ?? `execute_tool ${hookCtx.toolName}`;
|
|
347
|
+
const baseAttrs = {
|
|
348
|
+
"gen_ai.tool.name": hookCtx.toolName,
|
|
349
|
+
"gen_ai.tool.call.id": hookCtx.toolCallId,
|
|
350
|
+
"gen_ai.tool.type": "function"
|
|
351
|
+
};
|
|
352
|
+
const baseOptions = {
|
|
353
|
+
kind: SpanKind.INTERNAL,
|
|
354
|
+
attributes: baseAttrs
|
|
355
|
+
};
|
|
356
|
+
const spanOptions = safeCall(
|
|
357
|
+
"otel.onBeforeSpanStart",
|
|
358
|
+
() => onBeforeSpanStart?.(info, baseOptions)
|
|
359
|
+
) ?? baseOptions;
|
|
360
|
+
const parentCtx = trace.setSpan(context.active(), parent);
|
|
361
|
+
let toolSpan;
|
|
362
|
+
context.with(parentCtx, () => {
|
|
363
|
+
toolSpan = tracer.startSpan(name, spanOptions, parentCtx);
|
|
364
|
+
});
|
|
365
|
+
const enriched = safeCall(
|
|
366
|
+
"otel.attributeEnricher",
|
|
367
|
+
() => attributeEnricher?.(info)
|
|
368
|
+
);
|
|
369
|
+
if (enriched) toolSpan.setAttributes(enriched);
|
|
370
|
+
if (captureContent) {
|
|
371
|
+
const argsBody = typeof hookCtx.args === "string" ? hookCtx.args : safeCall(
|
|
372
|
+
"otel.serializeToolArgs",
|
|
373
|
+
() => JSON.stringify(hookCtx.args ?? null)
|
|
374
|
+
) ?? "[unserializable_tool_args]";
|
|
375
|
+
const redactedArgs = redactContent(argsBody);
|
|
376
|
+
const toolInputJson = JSON.stringify([
|
|
377
|
+
{ role: "tool", content: redactedArgs }
|
|
378
|
+
]);
|
|
379
|
+
toolSpan.setAttribute("gen_ai.input.messages", toolInputJson);
|
|
380
|
+
toolSpan.setAttribute("langfuse.observation.input", toolInputJson);
|
|
381
|
+
}
|
|
382
|
+
state.toolSpans.set(hookCtx.toolCallId, {
|
|
383
|
+
span: toolSpan,
|
|
384
|
+
toolName: hookCtx.toolName
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
return void 0;
|
|
388
|
+
},
|
|
389
|
+
onAfterToolCall(ctx, info) {
|
|
390
|
+
safeCall("otel.onAfterToolCall", () => {
|
|
391
|
+
const state = stateByCtx.get(ctx);
|
|
392
|
+
if (!state) return;
|
|
393
|
+
const entry = state.toolSpans.get(info.toolCallId);
|
|
394
|
+
if (!entry) return;
|
|
395
|
+
const { span: toolSpan } = entry;
|
|
396
|
+
const outcome = info.ok ? "success" : "error";
|
|
397
|
+
toolSpan.setAttribute("tanstack.ai.tool.outcome", outcome);
|
|
398
|
+
if (!info.ok && info.error !== void 0) {
|
|
399
|
+
toolSpan.recordException(info.error);
|
|
400
|
+
toolSpan.setStatus({
|
|
401
|
+
code: SpanStatusCode.ERROR,
|
|
402
|
+
message: errorMessage(info.error)
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (captureContent) {
|
|
406
|
+
const body = typeof info.result === "string" ? info.result : safeCall(
|
|
407
|
+
"otel.serializeToolResult",
|
|
408
|
+
() => JSON.stringify(info.result ?? null)
|
|
409
|
+
) ?? "[unserializable_tool_result]";
|
|
410
|
+
const redactedBody = redactContent(body);
|
|
411
|
+
if (state.currentIterationSpan) {
|
|
412
|
+
state.currentIterationSpan.addEvent("gen_ai.tool.message", {
|
|
413
|
+
content: redactedBody,
|
|
414
|
+
tool_call_id: info.toolCallId
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const toolOutputJson = JSON.stringify([
|
|
418
|
+
{ role: "tool", content: redactedBody }
|
|
419
|
+
]);
|
|
420
|
+
toolSpan.setAttribute("gen_ai.output.messages", toolOutputJson);
|
|
421
|
+
toolSpan.setAttribute("langfuse.observation.output", toolOutputJson);
|
|
422
|
+
}
|
|
423
|
+
safeCall(
|
|
424
|
+
"otel.onSpanEnd",
|
|
425
|
+
() => onSpanEnd?.(
|
|
426
|
+
{
|
|
427
|
+
kind: "tool",
|
|
428
|
+
ctx,
|
|
429
|
+
toolName: info.toolName,
|
|
430
|
+
toolCallId: info.toolCallId,
|
|
431
|
+
iteration: state.iterationCount - 1
|
|
432
|
+
},
|
|
433
|
+
toolSpan
|
|
434
|
+
)
|
|
435
|
+
);
|
|
436
|
+
toolSpan.end();
|
|
437
|
+
state.toolSpans.delete(info.toolCallId);
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
onError(ctx, info) {
|
|
441
|
+
safeCall("otel.onError", () => {
|
|
442
|
+
const state = stateByCtx.get(ctx);
|
|
443
|
+
if (!state) return;
|
|
444
|
+
const errType = errorTypeName(info.error);
|
|
445
|
+
const message = errorMessage(info.error);
|
|
446
|
+
const exception = info.error;
|
|
447
|
+
if (state.currentIterationSpan) {
|
|
448
|
+
state.currentIterationSpan.recordException(exception);
|
|
449
|
+
state.currentIterationSpan.setStatus({
|
|
450
|
+
code: SpanStatusCode.ERROR,
|
|
451
|
+
message
|
|
452
|
+
});
|
|
453
|
+
safeCall(
|
|
454
|
+
"otel.onSpanEnd",
|
|
455
|
+
() => onSpanEnd?.(
|
|
456
|
+
{
|
|
457
|
+
kind: "iteration",
|
|
458
|
+
ctx,
|
|
459
|
+
iteration: state.iterationCount - 1
|
|
460
|
+
},
|
|
461
|
+
state.currentIterationSpan
|
|
462
|
+
)
|
|
463
|
+
);
|
|
464
|
+
state.currentIterationSpan.end();
|
|
465
|
+
state.currentIterationSpan = null;
|
|
466
|
+
}
|
|
467
|
+
for (const [id, entry] of state.toolSpans) {
|
|
468
|
+
const { span, toolName } = entry;
|
|
469
|
+
span.recordException(exception);
|
|
470
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
471
|
+
safeCall(
|
|
472
|
+
"otel.onSpanEnd",
|
|
473
|
+
() => onSpanEnd?.(
|
|
474
|
+
{
|
|
475
|
+
kind: "tool",
|
|
476
|
+
ctx,
|
|
477
|
+
toolCallId: id,
|
|
478
|
+
toolName,
|
|
479
|
+
iteration: state.iterationCount - 1
|
|
480
|
+
},
|
|
481
|
+
span
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
span.end();
|
|
485
|
+
state.toolSpans.delete(id);
|
|
486
|
+
}
|
|
487
|
+
state.rootSpan.recordException(exception);
|
|
488
|
+
state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
489
|
+
if (durationHistogram) {
|
|
490
|
+
durationHistogram.record(info.duration / 1e3, {
|
|
491
|
+
"gen_ai.system": ctx.provider,
|
|
492
|
+
"gen_ai.operation.name": "chat",
|
|
493
|
+
"gen_ai.request.model": ctx.model,
|
|
494
|
+
"error.type": errType
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
safeCall(
|
|
498
|
+
"otel.onSpanEnd",
|
|
499
|
+
() => onSpanEnd?.({ kind: "chat", ctx }, state.rootSpan)
|
|
500
|
+
);
|
|
501
|
+
state.rootSpan.end();
|
|
502
|
+
stateByCtx.delete(ctx);
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
onAbort(ctx, info) {
|
|
506
|
+
safeCall("otel.onAbort", () => {
|
|
507
|
+
const state = stateByCtx.get(ctx);
|
|
508
|
+
if (!state) return;
|
|
509
|
+
const closeCancelled = (span) => {
|
|
510
|
+
span.setAttribute("tanstack.ai.completion.reason", "cancelled");
|
|
511
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: "cancelled" });
|
|
512
|
+
};
|
|
513
|
+
if (state.currentIterationSpan) {
|
|
514
|
+
closeCancelled(state.currentIterationSpan);
|
|
515
|
+
safeCall(
|
|
516
|
+
"otel.onSpanEnd",
|
|
517
|
+
() => onSpanEnd?.(
|
|
518
|
+
{
|
|
519
|
+
kind: "iteration",
|
|
520
|
+
ctx,
|
|
521
|
+
iteration: state.iterationCount - 1
|
|
522
|
+
},
|
|
523
|
+
state.currentIterationSpan
|
|
524
|
+
)
|
|
525
|
+
);
|
|
526
|
+
state.currentIterationSpan.end();
|
|
527
|
+
state.currentIterationSpan = null;
|
|
528
|
+
}
|
|
529
|
+
for (const [id, entry] of state.toolSpans) {
|
|
530
|
+
const { span, toolName } = entry;
|
|
531
|
+
closeCancelled(span);
|
|
532
|
+
safeCall(
|
|
533
|
+
"otel.onSpanEnd",
|
|
534
|
+
() => onSpanEnd?.(
|
|
535
|
+
{
|
|
536
|
+
kind: "tool",
|
|
537
|
+
ctx,
|
|
538
|
+
toolCallId: id,
|
|
539
|
+
toolName,
|
|
540
|
+
iteration: state.iterationCount - 1
|
|
541
|
+
},
|
|
542
|
+
span
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
span.end();
|
|
546
|
+
state.toolSpans.delete(id);
|
|
547
|
+
}
|
|
548
|
+
closeCancelled(state.rootSpan);
|
|
549
|
+
if (durationHistogram) {
|
|
550
|
+
durationHistogram.record(info.duration / 1e3, {
|
|
551
|
+
"gen_ai.system": ctx.provider,
|
|
552
|
+
"gen_ai.operation.name": "chat",
|
|
553
|
+
"gen_ai.request.model": ctx.model,
|
|
554
|
+
"error.type": "cancelled"
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
safeCall(
|
|
558
|
+
"otel.onSpanEnd",
|
|
559
|
+
() => onSpanEnd?.({ kind: "chat", ctx }, state.rootSpan)
|
|
560
|
+
);
|
|
561
|
+
state.rootSpan.end();
|
|
562
|
+
stateByCtx.delete(ctx);
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
onFinish(ctx, info) {
|
|
566
|
+
safeCall("otel.onFinish", () => {
|
|
567
|
+
const state = stateByCtx.get(ctx);
|
|
568
|
+
if (!state) return;
|
|
569
|
+
for (const [id, entry] of state.toolSpans) {
|
|
570
|
+
const { span, toolName } = entry;
|
|
571
|
+
span.setAttribute("tanstack.ai.tool.outcome", "unknown");
|
|
572
|
+
safeCall(
|
|
573
|
+
"otel.onSpanEnd",
|
|
574
|
+
() => onSpanEnd?.(
|
|
575
|
+
{
|
|
576
|
+
kind: "tool",
|
|
577
|
+
ctx,
|
|
578
|
+
toolCallId: id,
|
|
579
|
+
toolName,
|
|
580
|
+
iteration: state.iterationCount - 1
|
|
581
|
+
},
|
|
582
|
+
span
|
|
583
|
+
)
|
|
584
|
+
);
|
|
585
|
+
span.end();
|
|
586
|
+
state.toolSpans.delete(id);
|
|
587
|
+
}
|
|
588
|
+
closeIterationSpan(state, ctx);
|
|
589
|
+
if (durationHistogram) {
|
|
590
|
+
durationHistogram.record(info.duration / 1e3, {
|
|
591
|
+
"gen_ai.system": ctx.provider,
|
|
592
|
+
"gen_ai.operation.name": "chat",
|
|
593
|
+
"gen_ai.request.model": ctx.model
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (info.usage) {
|
|
597
|
+
state.rootSpan.setAttributes({
|
|
598
|
+
"gen_ai.usage.input_tokens": info.usage.promptTokens,
|
|
599
|
+
"gen_ai.usage.output_tokens": info.usage.completionTokens
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (info.finishReason) {
|
|
603
|
+
state.rootSpan.setAttribute("gen_ai.response.finish_reasons", [
|
|
604
|
+
info.finishReason
|
|
605
|
+
]);
|
|
606
|
+
}
|
|
607
|
+
state.rootSpan.setAttribute(
|
|
608
|
+
"tanstack.ai.iterations",
|
|
609
|
+
state.iterationCount
|
|
610
|
+
);
|
|
611
|
+
safeCall(
|
|
612
|
+
"otel.onSpanEnd",
|
|
613
|
+
() => onSpanEnd?.({ kind: "chat", ctx }, state.rootSpan)
|
|
614
|
+
);
|
|
615
|
+
state.rootSpan.end();
|
|
616
|
+
stateByCtx.delete(ctx);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
export {
|
|
622
|
+
otelMiddleware
|
|
623
|
+
};
|
|
624
|
+
//# sourceMappingURL=otel.js.map
|