@tracepanel/js 0.5.0 → 0.7.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/README.md +265 -14
- package/dist/agent.d.ts +5 -1
- package/dist/agent.js +12 -3
- package/dist/agent.js.map +1 -1
- package/dist/aws.d.ts +27 -0
- package/dist/aws.js +368 -0
- package/dist/aws.js.map +1 -0
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +33 -3
- package/dist/browser.js.map +1 -1
- package/dist/bun.js +6 -82
- package/dist/bun.js.map +1 -1
- package/dist/calls.d.ts +5 -0
- package/dist/calls.js +7 -0
- package/dist/calls.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/explain.d.ts +5 -0
- package/dist/explain.js +56 -7
- package/dist/explain.js.map +1 -1
- package/dist/express.js +43 -3
- package/dist/express.js.map +1 -1
- package/dist/fastify.d.ts +2 -0
- package/dist/fastify.js +100 -0
- package/dist/fastify.js.map +1 -0
- package/dist/frameworks.js +3 -0
- package/dist/frameworks.js.map +1 -1
- package/dist/graphql.d.ts +39 -0
- package/dist/graphql.js +337 -0
- package/dist/graphql.js.map +1 -0
- package/dist/hono.d.ts +2 -0
- package/dist/hono.js +63 -0
- package/dist/hono.js.map +1 -0
- package/dist/koa.d.ts +2 -0
- package/dist/koa.js +119 -0
- package/dist/koa.js.map +1 -0
- package/dist/llm.d.ts +20 -0
- package/dist/llm.js +632 -0
- package/dist/llm.js.map +1 -0
- package/dist/memory.d.ts +69 -0
- package/dist/memory.js +203 -0
- package/dist/memory.js.map +1 -0
- package/dist/model.d.ts +22 -1
- package/dist/model.js.map +1 -1
- package/dist/next.js +4 -4
- package/dist/next.js.map +1 -1
- package/dist/processor.d.ts +50 -5
- package/dist/processor.js +193 -30
- package/dist/processor.js.map +1 -1
- package/dist/profile.d.ts +34 -8
- package/dist/profile.js +98 -13
- package/dist/profile.js.map +1 -1
- package/dist/propagation.d.ts +12 -0
- package/dist/propagation.js +48 -0
- package/dist/propagation.js.map +1 -0
- package/dist/queries.d.ts +20 -0
- package/dist/queries.js +75 -0
- package/dist/queries.js.map +1 -0
- package/dist/respond.d.ts +12 -0
- package/dist/respond.js +85 -0
- package/dist/respond.js.map +1 -0
- package/dist/setup.js +27 -6
- package/dist/setup.js.map +1 -1
- package/dist/sql.d.ts +1 -0
- package/dist/sql.js +11 -2
- package/dist/sql.js.map +1 -1
- package/dist/sqlite.d.ts +13 -0
- package/dist/sqlite.js +291 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/stack.d.ts +3 -1
- package/dist/stack.js +30 -6
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +128 -0
- package/dist/testing.js +362 -0
- package/dist/testing.js.map +1 -0
- package/dist/transactions.d.ts +11 -1
- package/dist/transactions.js +70 -34
- package/dist/transactions.js.map +1 -1
- package/dist/view.d.ts +17 -0
- package/dist/view.js +71 -0
- package/dist/view.js.map +1 -0
- package/dist/vitest-setup.d.ts +1 -0
- package/dist/vitest-setup.js +63 -0
- package/dist/vitest-setup.js.map +1 -0
- package/dist/vitest.d.ts +57 -0
- package/dist/vitest.js +130 -0
- package/dist/vitest.js.map +1 -0
- package/dist/widget.d.ts +16 -0
- package/dist/widget.js +42 -1
- package/dist/widget.js.map +1 -1
- package/package.json +81 -4
package/dist/llm.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
// LLM calls: each call the app makes to a model through OpenAI's SDK (`openai`), Anthropic's (`@anthropic-ai/sdk`) or
|
|
2
|
+
// any other client of their APIs (the Vercel AI SDK's providers), as a span by OpenTelemetry's GenAI conventions, as
|
|
3
|
+
// the Python SDK records them: `chat gpt-5`, `chat claude-sonnet-5-5`, `embeddings text-embedding-3-small`.
|
|
4
|
+
//
|
|
5
|
+
// The SDKs' clients are wrapped where every API call goes (post(), which retries): a call to an endpoint known here
|
|
6
|
+
// is a span under the span current where the app made it, its HTTP calls (one per attempt) its children. Anything
|
|
7
|
+
// else that calls these endpoints with fetch() (the AI SDK's OpenAI and Anthropic providers, an OpenAI-compatible
|
|
8
|
+
// API's client) gets the same span around its request. Its attributes are the conventions' `gen_ai.*`: the provider,
|
|
9
|
+
// the operation, the model asked for and the one that answered, the request's settings (max_tokens, temperature),
|
|
10
|
+
// why the model stopped, the tokens it used (cost is the agent's to work out), and the names of the tools it called.
|
|
11
|
+
// A failed call (a rate limit, an overloaded API, a timeout) is an error, `error.type` its error's class (an HTTP
|
|
12
|
+
// status for a fetch()), with the HTTP status the API answered.
|
|
13
|
+
//
|
|
14
|
+
// A streamed answer's span lasts until its last event: its first event's time is
|
|
15
|
+
// `gen_ai.response.time_to_first_chunk` (seconds, as the conventions have it), and the usage the last events carry
|
|
16
|
+
// is counted. The conventions have no attribute for the tools a model called: `gen_ai.response.tool_calls` names
|
|
17
|
+
// them, in order, and `gen_ai.response.tool_call_count` counts them. The messages themselves are not recorded.
|
|
18
|
+
//
|
|
19
|
+
// The spans are of their own kind, `llm` (`tracepanel.span_kind`). Anthropic's SDK makes an OpenTelemetry span of
|
|
20
|
+
// its own for each call: the processor leaves those out, so a call shows once.
|
|
21
|
+
import { context, createContextKey, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
22
|
+
import { InstrumentationBase, InstrumentationNodeModuleDefinition, isWrapped } from "@opentelemetry/instrumentation";
|
|
23
|
+
/** The span kind of an LLM call, in `tracepanel.span_kind`. */
|
|
24
|
+
export const KIND = "llm";
|
|
25
|
+
/** The instrumentation scope of Anthropic's SDK's own spans: the processor leaves them out. */
|
|
26
|
+
export const ANTHROPIC_SCOPE = "com.anthropic.sdk";
|
|
27
|
+
/** Set in the context of an LLM call's span: the requests made inside it are the call's. */
|
|
28
|
+
const IN_CALL = createContextKey("tracepanel.llm_call");
|
|
29
|
+
const tracer = trace.getTracer("tracepanel-llm");
|
|
30
|
+
function get(value, name) {
|
|
31
|
+
return value !== null && typeof value === "object" ? value[name] : undefined;
|
|
32
|
+
}
|
|
33
|
+
function items(value) {
|
|
34
|
+
return Array.isArray(value) ? value : [];
|
|
35
|
+
}
|
|
36
|
+
function int(value) {
|
|
37
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
function str(value) {
|
|
40
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
function scalar(value) {
|
|
43
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** `gen_ai.request.<attr>` for each request field in `names` (field → attr) the body has. */
|
|
46
|
+
function settings(body, names) {
|
|
47
|
+
const attrs = {};
|
|
48
|
+
for (const [name, attr] of Object.entries(names)) {
|
|
49
|
+
const value = body[name];
|
|
50
|
+
if (typeof value === "string" && attr === "stop_sequences")
|
|
51
|
+
attrs[`gen_ai.request.${attr}`] = [value];
|
|
52
|
+
else if (Array.isArray(value))
|
|
53
|
+
attrs[`gen_ai.request.${attr}`] = value.map(String);
|
|
54
|
+
else if (scalar(value) !== undefined)
|
|
55
|
+
attrs[`gen_ai.request.${attr}`] = value;
|
|
56
|
+
}
|
|
57
|
+
return attrs;
|
|
58
|
+
}
|
|
59
|
+
function usageAttrs(usage) {
|
|
60
|
+
const attrs = {};
|
|
61
|
+
const counts = [
|
|
62
|
+
[usage.input, "input_tokens"],
|
|
63
|
+
[usage.output, "output_tokens"],
|
|
64
|
+
[usage.cacheRead, "cache_read.input_tokens"],
|
|
65
|
+
[usage.cacheWrite, "cache_write.input_tokens"],
|
|
66
|
+
[usage.reasoning, "reasoning.output_tokens"],
|
|
67
|
+
];
|
|
68
|
+
for (const [value, attr] of counts) {
|
|
69
|
+
// The detailed counts only when there are some, as the conventions ask.
|
|
70
|
+
if (int(value) !== undefined && (value || attr === "input_tokens" || attr === "output_tokens")) {
|
|
71
|
+
attrs[`gen_ai.usage.${attr}`] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return attrs;
|
|
75
|
+
}
|
|
76
|
+
function reply() {
|
|
77
|
+
return { finishReasons: [], usage: {}, tools: [] };
|
|
78
|
+
}
|
|
79
|
+
function replyAttrs(found) {
|
|
80
|
+
const attrs = {};
|
|
81
|
+
if (found.id)
|
|
82
|
+
attrs["gen_ai.response.id"] = found.id;
|
|
83
|
+
if (found.model)
|
|
84
|
+
attrs["gen_ai.response.model"] = found.model;
|
|
85
|
+
if (found.finishReasons.length)
|
|
86
|
+
attrs["gen_ai.response.finish_reasons"] = found.finishReasons;
|
|
87
|
+
Object.assign(attrs, usageAttrs(found.usage));
|
|
88
|
+
if (found.tools.length) {
|
|
89
|
+
attrs["gen_ai.response.tool_calls"] = found.tools;
|
|
90
|
+
attrs["gen_ai.response.tool_call_count"] = found.tools.length;
|
|
91
|
+
}
|
|
92
|
+
return attrs;
|
|
93
|
+
}
|
|
94
|
+
function model(body) {
|
|
95
|
+
return typeof body.model === "string" ? { "gen_ai.request.model": body.model } : {};
|
|
96
|
+
}
|
|
97
|
+
// ---------- OpenAI ----------
|
|
98
|
+
const CHAT_SETTINGS = {
|
|
99
|
+
max_tokens: "max_tokens",
|
|
100
|
+
max_completion_tokens: "max_tokens",
|
|
101
|
+
temperature: "temperature",
|
|
102
|
+
top_p: "top_p",
|
|
103
|
+
frequency_penalty: "frequency_penalty",
|
|
104
|
+
presence_penalty: "presence_penalty",
|
|
105
|
+
seed: "seed",
|
|
106
|
+
stop: "stop_sequences",
|
|
107
|
+
reasoning_effort: "reasoning.level",
|
|
108
|
+
};
|
|
109
|
+
const RESPONSES_SETTINGS = {
|
|
110
|
+
max_output_tokens: "max_tokens",
|
|
111
|
+
temperature: "temperature",
|
|
112
|
+
top_p: "top_p",
|
|
113
|
+
previous_response_id: "previous_response.id",
|
|
114
|
+
};
|
|
115
|
+
function openaiUsage(usage, input, output) {
|
|
116
|
+
if (usage === null || usage === undefined)
|
|
117
|
+
return {};
|
|
118
|
+
return {
|
|
119
|
+
input: int(get(usage, input)),
|
|
120
|
+
output: int(get(usage, output)),
|
|
121
|
+
cacheRead: int(get(get(usage, `${input}_details`), "cached_tokens")),
|
|
122
|
+
reasoning: int(get(get(usage, `${output}_details`), "reasoning_tokens")),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function chatRequest(body) {
|
|
126
|
+
const attrs = { ...model(body), ...settings(body, CHAT_SETTINGS) };
|
|
127
|
+
const choices = int(body.n);
|
|
128
|
+
if (choices !== undefined && choices !== 1)
|
|
129
|
+
attrs["gen_ai.request.choice.count"] = choices;
|
|
130
|
+
return attrs;
|
|
131
|
+
}
|
|
132
|
+
function chatReply(answer) {
|
|
133
|
+
const found = {
|
|
134
|
+
...reply(),
|
|
135
|
+
id: str(get(answer, "id")),
|
|
136
|
+
model: str(get(answer, "model")),
|
|
137
|
+
usage: openaiUsage(get(answer, "usage"), "prompt_tokens", "completion_tokens"),
|
|
138
|
+
};
|
|
139
|
+
for (const choice of items(get(answer, "choices"))) {
|
|
140
|
+
const reason = get(choice, "finish_reason");
|
|
141
|
+
found.finishReasons.push(reason === null || reason === undefined ? "error" : String(reason));
|
|
142
|
+
for (const call of items(get(get(choice, "message"), "tool_calls"))) {
|
|
143
|
+
found.tools.push(String(get(get(call, "function"), "name")));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
/** A streamed chat completion: each choice's tool calls and finish reason, the usage at the end. */
|
|
149
|
+
function chatEvents() {
|
|
150
|
+
const found = reply();
|
|
151
|
+
const choices = new Map();
|
|
152
|
+
return {
|
|
153
|
+
add(event) {
|
|
154
|
+
found.id ??= str(get(event, "id"));
|
|
155
|
+
found.model ??= str(get(event, "model"));
|
|
156
|
+
const usage = get(event, "usage");
|
|
157
|
+
if (usage !== null && usage !== undefined)
|
|
158
|
+
found.usage = openaiUsage(usage, "prompt_tokens", "completion_tokens");
|
|
159
|
+
for (const choice of items(get(event, "choices"))) {
|
|
160
|
+
const index = int(get(choice, "index")) ?? 0;
|
|
161
|
+
const known = choices.get(index) ?? { calls: new Map(), reasons: [] };
|
|
162
|
+
choices.set(index, known);
|
|
163
|
+
for (const call of items(get(get(choice, "delta"), "tool_calls"))) {
|
|
164
|
+
const at = int(get(call, "index")) ?? 0;
|
|
165
|
+
const name = str(get(get(call, "function"), "name"));
|
|
166
|
+
if (name || !known.calls.has(at))
|
|
167
|
+
known.calls.set(at, name ?? known.calls.get(at) ?? "");
|
|
168
|
+
}
|
|
169
|
+
const reason = get(choice, "finish_reason");
|
|
170
|
+
if (reason !== null && reason !== undefined)
|
|
171
|
+
known.reasons.push(String(reason));
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
reply() {
|
|
175
|
+
for (const index of [...choices.keys()].sort((a, b) => a - b)) {
|
|
176
|
+
const { calls, reasons } = choices.get(index);
|
|
177
|
+
found.finishReasons.push(reasons.at(-1) ?? "error");
|
|
178
|
+
found.tools.push(...[...calls.keys()].sort((a, b) => a - b).map((at) => calls.get(at)));
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function responsesRequest(body) {
|
|
185
|
+
const attrs = { ...model(body), ...settings(body, RESPONSES_SETTINGS) };
|
|
186
|
+
const effort = get(body.reasoning, "effort");
|
|
187
|
+
if (typeof effort === "string")
|
|
188
|
+
attrs["gen_ai.request.reasoning.level"] = effort;
|
|
189
|
+
return attrs;
|
|
190
|
+
}
|
|
191
|
+
function responseReply(answer) {
|
|
192
|
+
const reason = str(get(get(answer, "incomplete_details"), "reason")) ?? str(get(answer, "status")) ?? "error";
|
|
193
|
+
const found = {
|
|
194
|
+
...reply(),
|
|
195
|
+
id: str(get(answer, "id")),
|
|
196
|
+
model: str(get(answer, "model")),
|
|
197
|
+
finishReasons: [reason],
|
|
198
|
+
usage: openaiUsage(get(answer, "usage"), "input_tokens", "output_tokens"),
|
|
199
|
+
};
|
|
200
|
+
for (const item of items(get(answer, "output"))) {
|
|
201
|
+
const kind = get(item, "type");
|
|
202
|
+
if (kind === "function_call" || kind === "custom_tool_call")
|
|
203
|
+
found.tools.push(String(get(item, "name")));
|
|
204
|
+
}
|
|
205
|
+
return found;
|
|
206
|
+
}
|
|
207
|
+
/** A streamed response: its events end with the whole response (completed, incomplete or failed). */
|
|
208
|
+
function responseEvents() {
|
|
209
|
+
let response;
|
|
210
|
+
return {
|
|
211
|
+
add(event) {
|
|
212
|
+
const found = get(event, "response");
|
|
213
|
+
if (found !== undefined && found !== null)
|
|
214
|
+
response = found;
|
|
215
|
+
},
|
|
216
|
+
reply: () => (response === undefined ? { ...reply(), finishReasons: ["error"] } : responseReply(response)),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function embeddingsRequest(body) {
|
|
220
|
+
const attrs = model(body);
|
|
221
|
+
if (typeof body.encoding_format === "string")
|
|
222
|
+
attrs["gen_ai.request.encoding_formats"] = [body.encoding_format];
|
|
223
|
+
const dimensions = int(body.dimensions);
|
|
224
|
+
if (dimensions !== undefined)
|
|
225
|
+
attrs["gen_ai.embeddings.dimension.count"] = dimensions;
|
|
226
|
+
return attrs;
|
|
227
|
+
}
|
|
228
|
+
function embeddingsReply(answer) {
|
|
229
|
+
return {
|
|
230
|
+
...reply(),
|
|
231
|
+
model: str(get(answer, "model")),
|
|
232
|
+
usage: { input: int(get(get(answer, "usage"), "prompt_tokens")) },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// ---------- Anthropic ----------
|
|
236
|
+
const ANTHROPIC_SETTINGS = {
|
|
237
|
+
max_tokens: "max_tokens",
|
|
238
|
+
temperature: "temperature",
|
|
239
|
+
top_p: "top_p",
|
|
240
|
+
top_k: "top_k",
|
|
241
|
+
stop_sequences: "stop_sequences",
|
|
242
|
+
};
|
|
243
|
+
const TOOL_USES = new Set(["tool_use", "server_tool_use", "mcp_tool_use"]);
|
|
244
|
+
function messagesRequest(body) {
|
|
245
|
+
const attrs = { ...model(body), ...settings(body, ANTHROPIC_SETTINGS) };
|
|
246
|
+
const effort = get(body.output_config, "effort");
|
|
247
|
+
if (typeof effort === "string")
|
|
248
|
+
attrs["gen_ai.request.reasoning.level"] = effort;
|
|
249
|
+
return attrs;
|
|
250
|
+
}
|
|
251
|
+
/** The usage a message or its stream's last event reports, over what an earlier event did. */
|
|
252
|
+
function anthropicUsage(usage, into = {}) {
|
|
253
|
+
if (usage === null || usage === undefined)
|
|
254
|
+
return into;
|
|
255
|
+
const read = int(get(usage, "cache_read_input_tokens"));
|
|
256
|
+
const written = int(get(usage, "cache_creation_input_tokens"));
|
|
257
|
+
const found = { ...into };
|
|
258
|
+
if (read !== undefined)
|
|
259
|
+
found.cacheRead = read;
|
|
260
|
+
if (written !== undefined)
|
|
261
|
+
found.cacheWrite = written;
|
|
262
|
+
const input = int(get(usage, "input_tokens"));
|
|
263
|
+
if (input !== undefined)
|
|
264
|
+
found.input = input + (found.cacheRead ?? 0) + (found.cacheWrite ?? 0);
|
|
265
|
+
const output = int(get(usage, "output_tokens"));
|
|
266
|
+
if (output !== undefined)
|
|
267
|
+
found.output = output;
|
|
268
|
+
return found;
|
|
269
|
+
}
|
|
270
|
+
function messageReply(answer) {
|
|
271
|
+
const reason = get(answer, "stop_reason");
|
|
272
|
+
return {
|
|
273
|
+
id: str(get(answer, "id")),
|
|
274
|
+
model: str(get(answer, "model")),
|
|
275
|
+
finishReasons: [reason === null || reason === undefined ? "error" : String(reason)],
|
|
276
|
+
usage: anthropicUsage(get(answer, "usage")),
|
|
277
|
+
tools: items(get(answer, "content"))
|
|
278
|
+
.filter((block) => TOOL_USES.has(String(get(block, "type"))))
|
|
279
|
+
.map((block) => String(get(block, "name"))),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** A streamed message: message_start's id, model and input usage, the tools its blocks call, message_delta's end. */
|
|
283
|
+
function messageEvents() {
|
|
284
|
+
const found = reply();
|
|
285
|
+
let reason;
|
|
286
|
+
return {
|
|
287
|
+
add(event) {
|
|
288
|
+
const kind = get(event, "type");
|
|
289
|
+
if (kind === "message_start") {
|
|
290
|
+
const message = get(event, "message");
|
|
291
|
+
found.id = str(get(message, "id"));
|
|
292
|
+
found.model = str(get(message, "model"));
|
|
293
|
+
found.usage = anthropicUsage(get(message, "usage"), found.usage);
|
|
294
|
+
}
|
|
295
|
+
else if (kind === "content_block_start") {
|
|
296
|
+
const block = get(event, "content_block");
|
|
297
|
+
if (TOOL_USES.has(String(get(block, "type"))))
|
|
298
|
+
found.tools.push(String(get(block, "name")));
|
|
299
|
+
}
|
|
300
|
+
else if (kind === "message_delta") {
|
|
301
|
+
reason = str(get(get(event, "delta"), "stop_reason")) ?? reason;
|
|
302
|
+
found.usage = anthropicUsage(get(event, "usage"), found.usage);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
reply: () => ({ ...found, finishReasons: [reason ?? "error"] }),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// ---------- the endpoints ----------
|
|
309
|
+
/** The endpoints, by the end of their path: an SDK's client calls them by it, fetch() by the whole URL. */
|
|
310
|
+
const ENDPOINTS = {
|
|
311
|
+
"/chat/completions": {
|
|
312
|
+
provider: "openai",
|
|
313
|
+
operation: "chat",
|
|
314
|
+
request: chatRequest,
|
|
315
|
+
reply: chatReply,
|
|
316
|
+
events: chatEvents,
|
|
317
|
+
extra: { "openai.api.type": "chat_completions" },
|
|
318
|
+
},
|
|
319
|
+
"/responses": {
|
|
320
|
+
provider: "openai",
|
|
321
|
+
operation: "chat",
|
|
322
|
+
request: responsesRequest,
|
|
323
|
+
reply: responseReply,
|
|
324
|
+
events: responseEvents,
|
|
325
|
+
extra: { "openai.api.type": "responses" },
|
|
326
|
+
},
|
|
327
|
+
"/embeddings": { provider: "openai", operation: "embeddings", request: embeddingsRequest, reply: embeddingsReply },
|
|
328
|
+
"/v1/messages": {
|
|
329
|
+
provider: "anthropic",
|
|
330
|
+
operation: "chat",
|
|
331
|
+
request: messagesRequest,
|
|
332
|
+
reply: messageReply,
|
|
333
|
+
events: messageEvents,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
function endpoint(path) {
|
|
337
|
+
const clean = path.split("?")[0]?.replace(/\/+$/, "") ?? "";
|
|
338
|
+
for (const [end, found] of Object.entries(ENDPOINTS)) {
|
|
339
|
+
if (clean === end || clean.endsWith(end))
|
|
340
|
+
return found;
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
/** The provider an API's host is, by the conventions' names; an unknown host's is the API's own. */
|
|
345
|
+
const HOSTS = [
|
|
346
|
+
[/(^|\.)openai\.azure\.com$/, "azure.ai.openai"],
|
|
347
|
+
[/(^|\.)api\.groq\.com$/, "groq"],
|
|
348
|
+
[/(^|\.)api\.deepseek\.com$/, "deepseek"],
|
|
349
|
+
[/(^|\.)api\.mistral\.ai$/, "mistral_ai"],
|
|
350
|
+
[/(^|\.)api\.x\.ai$/, "x_ai"],
|
|
351
|
+
[/(^|\.)api\.perplexity\.ai$/, "perplexity"],
|
|
352
|
+
];
|
|
353
|
+
function server(url) {
|
|
354
|
+
try {
|
|
355
|
+
const { hostname, port, protocol } = new URL(String(url));
|
|
356
|
+
if (!hostname)
|
|
357
|
+
return {};
|
|
358
|
+
return { "server.address": hostname, "server.port": port ? Number(port) : protocol === "https:" ? 443 : 80 };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ---------- a call ----------
|
|
365
|
+
/** One API call's span: open while the call is made, then until its stream's last event. */
|
|
366
|
+
class Call {
|
|
367
|
+
span;
|
|
368
|
+
found;
|
|
369
|
+
events;
|
|
370
|
+
first = false;
|
|
371
|
+
done = false;
|
|
372
|
+
started = performance.now();
|
|
373
|
+
stream = false;
|
|
374
|
+
constructor(span, found) {
|
|
375
|
+
this.span = span;
|
|
376
|
+
this.found = found;
|
|
377
|
+
}
|
|
378
|
+
/** What the app asked for: the model, its settings, a stream. */
|
|
379
|
+
describe(body, stream) {
|
|
380
|
+
const request = this.found.request(body);
|
|
381
|
+
const asked = request["gen_ai.request.model"];
|
|
382
|
+
this.stream = stream;
|
|
383
|
+
this.span.setAttributes({ ...request, ...(stream ? { "gen_ai.request.stream": true } : {}) });
|
|
384
|
+
if (typeof asked === "string")
|
|
385
|
+
this.span.updateName(`${this.found.operation} ${asked}`);
|
|
386
|
+
}
|
|
387
|
+
/** The call failed: its span is an error, ended now. */
|
|
388
|
+
failed(type, status) {
|
|
389
|
+
if (this.done)
|
|
390
|
+
return;
|
|
391
|
+
this.done = true;
|
|
392
|
+
this.span.setStatus({ code: SpanStatusCode.ERROR });
|
|
393
|
+
this.span.setAttribute("error.type", type);
|
|
394
|
+
if (status !== undefined)
|
|
395
|
+
this.span.setAttribute("http.response.status_code", status);
|
|
396
|
+
this.span.end();
|
|
397
|
+
}
|
|
398
|
+
/** The call's error, as the SDK threw it: its class, and the HTTP status the API answered. */
|
|
399
|
+
thrown(error) {
|
|
400
|
+
const status = int(get(error, "status"));
|
|
401
|
+
this.failed(error?.constructor?.name || "Error", status);
|
|
402
|
+
}
|
|
403
|
+
/** The answer: read now, or its stream followed to its end. A copy of it: the app reads the response itself. */
|
|
404
|
+
answered(response) {
|
|
405
|
+
if (this.done)
|
|
406
|
+
return;
|
|
407
|
+
let copy;
|
|
408
|
+
try {
|
|
409
|
+
copy = response.clone();
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
this.finish();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const streamed = this.stream || (copy.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
416
|
+
if (streamed && this.found.events && copy.body) {
|
|
417
|
+
this.events = this.found.events();
|
|
418
|
+
this.follow(copy.body).catch(() => this.finish());
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
copy
|
|
422
|
+
.json()
|
|
423
|
+
.then((answer) => this.finish(replyAttrs(this.found.reply(answer))))
|
|
424
|
+
.catch(() => this.finish());
|
|
425
|
+
}
|
|
426
|
+
async follow(body) {
|
|
427
|
+
const decoder = new TextDecoder();
|
|
428
|
+
let buffered = "";
|
|
429
|
+
try {
|
|
430
|
+
for await (const piece of body) {
|
|
431
|
+
buffered += decoder.decode(piece, { stream: true });
|
|
432
|
+
const blocks = buffered.split(/\r?\n\r?\n/);
|
|
433
|
+
buffered = blocks.pop() ?? "";
|
|
434
|
+
for (const block of blocks)
|
|
435
|
+
this.event(block);
|
|
436
|
+
}
|
|
437
|
+
this.event(buffered);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// The app closed the stream (or the connection broke): what came until then is the answer.
|
|
441
|
+
}
|
|
442
|
+
this.finish(this.events ? replyAttrs(this.events.reply()) : {});
|
|
443
|
+
}
|
|
444
|
+
/** One server-sent event: its data lines, JSON. */
|
|
445
|
+
event(block) {
|
|
446
|
+
const data = block
|
|
447
|
+
.split(/\r?\n/)
|
|
448
|
+
.filter((line) => line.startsWith("data:"))
|
|
449
|
+
.map((line) => line.slice(5).replace(/^ /, ""))
|
|
450
|
+
.join("\n");
|
|
451
|
+
if (!data || data === "[DONE]")
|
|
452
|
+
return;
|
|
453
|
+
if (!this.first) {
|
|
454
|
+
this.first = true;
|
|
455
|
+
const seconds = (performance.now() - this.started) / 1000;
|
|
456
|
+
this.span.setAttribute("gen_ai.response.time_to_first_chunk", Math.round(seconds * 1e6) / 1e6);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
this.events?.add(JSON.parse(data));
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Not JSON: not an event of the API's.
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
finish(attrs = {}) {
|
|
466
|
+
if (this.done)
|
|
467
|
+
return;
|
|
468
|
+
this.done = true;
|
|
469
|
+
this.span.setAttributes(attrs);
|
|
470
|
+
this.span.end();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** The call's span, open, when the app makes a call to a known endpoint inside a trace. */
|
|
474
|
+
function begin(found, provider, url) {
|
|
475
|
+
const attributes = {
|
|
476
|
+
"tracepanel.span_kind": KIND,
|
|
477
|
+
"gen_ai.provider.name": provider,
|
|
478
|
+
"gen_ai.operation.name": found.operation,
|
|
479
|
+
...found.extra,
|
|
480
|
+
...server(url),
|
|
481
|
+
};
|
|
482
|
+
const span = tracer.startSpan(found.operation, { kind: SpanKind.CLIENT, attributes: attributes });
|
|
483
|
+
return new Call(span, found);
|
|
484
|
+
}
|
|
485
|
+
/** The context an LLM call's requests are made in: its span's. */
|
|
486
|
+
function inside(call) {
|
|
487
|
+
return trace.setSpan(context.active(), call.span).setValue(IN_CALL, true);
|
|
488
|
+
}
|
|
489
|
+
/** Whether the current span is an LLM call already: ours, or another instrumentation's model call. */
|
|
490
|
+
function inCall() {
|
|
491
|
+
if (context.active().getValue(IN_CALL))
|
|
492
|
+
return true;
|
|
493
|
+
const operation = trace.getActiveSpan()?.attributes?.["gen_ai.operation.name"];
|
|
494
|
+
return operation === "chat" || operation === "text_completion" || operation === "embeddings";
|
|
495
|
+
}
|
|
496
|
+
/** gen_ai.provider.name for a client, by its class. */
|
|
497
|
+
function clientProvider(client, found) {
|
|
498
|
+
const name = client.constructor?.name ?? "";
|
|
499
|
+
if (found.provider === "openai")
|
|
500
|
+
return name.includes("Azure") ? "azure.ai.openai" : "openai";
|
|
501
|
+
if (name.includes("Bedrock"))
|
|
502
|
+
return "aws.bedrock";
|
|
503
|
+
if (name.includes("Vertex"))
|
|
504
|
+
return "gcp.vertex_ai";
|
|
505
|
+
return "anthropic";
|
|
506
|
+
}
|
|
507
|
+
/** The client's post(): a call to a known endpoint, with its retries, as one span. */
|
|
508
|
+
function tracedPost(original) {
|
|
509
|
+
return function (path, opts) {
|
|
510
|
+
const found = typeof path === "string" && trace.getActiveSpan() && !inCall() ? endpoint(path) : undefined;
|
|
511
|
+
if (!found)
|
|
512
|
+
return original.call(this, path, opts);
|
|
513
|
+
const call = begin(found, clientProvider(this, found), this.baseURL);
|
|
514
|
+
// The request's options, or (OpenAI's) a promise of them, which the SDK awaits before it sends the request.
|
|
515
|
+
const described = (given) => {
|
|
516
|
+
const body = get(given, "body");
|
|
517
|
+
call.describe(body !== null && typeof body === "object" ? body : {}, get(given, "stream") === true);
|
|
518
|
+
};
|
|
519
|
+
if (typeof opts?.then === "function") {
|
|
520
|
+
opts.then(described, () => { });
|
|
521
|
+
}
|
|
522
|
+
else
|
|
523
|
+
described(opts);
|
|
524
|
+
let promise;
|
|
525
|
+
try {
|
|
526
|
+
promise = context.with(inside(call), () => original.call(this, path, opts));
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
call.thrown(error);
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
const answer = promise?.responsePromise;
|
|
533
|
+
if (!answer || typeof answer.then !== "function") {
|
|
534
|
+
call.failed("Error");
|
|
535
|
+
return promise;
|
|
536
|
+
}
|
|
537
|
+
// The SDK's promise parses the response when the app awaits it: the call's span reads a copy as it arrives.
|
|
538
|
+
const followed = answer.then((props) => {
|
|
539
|
+
call.answered(props.response);
|
|
540
|
+
return props;
|
|
541
|
+
}, (error) => {
|
|
542
|
+
call.thrown(error);
|
|
543
|
+
throw error;
|
|
544
|
+
});
|
|
545
|
+
// Its failure is the app's to handle where it awaits the call, as the SDK's own promise's is.
|
|
546
|
+
followed.catch(() => { });
|
|
547
|
+
promise.responsePromise = followed;
|
|
548
|
+
return promise;
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/** OpenAI's and Anthropic's clients, their API calls as LLM call spans. */
|
|
552
|
+
export class LlmInstrumentation extends InstrumentationBase {
|
|
553
|
+
constructor() {
|
|
554
|
+
super("tracepanel-llm", "1.0.0", {});
|
|
555
|
+
}
|
|
556
|
+
init() {
|
|
557
|
+
return [
|
|
558
|
+
new InstrumentationNodeModuleDefinition("openai", [">=4 <8"], (exports) => this.patch(exports, ["OpenAI", "default"])),
|
|
559
|
+
new InstrumentationNodeModuleDefinition("@anthropic-ai/sdk", [">=0.20 <2"], (exports) => this.patch(exports, ["Anthropic", "default"])),
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
patch(exports, names) {
|
|
563
|
+
for (const name of names) {
|
|
564
|
+
const prototype = exports[name]?.prototype;
|
|
565
|
+
if (typeof prototype?.post === "function" && !isWrapped(prototype.post)) {
|
|
566
|
+
this._wrap(prototype, "post", (original) => tracedPost(original));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return exports;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// ---------- fetch() ----------
|
|
573
|
+
function urlOf(input) {
|
|
574
|
+
try {
|
|
575
|
+
return new URL(input instanceof Request ? input.url : String(input));
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function fetchProvider(url, found) {
|
|
582
|
+
return HOSTS.find(([host]) => host.test(url.hostname))?.[1] ?? found.provider;
|
|
583
|
+
}
|
|
584
|
+
const WRAPPED = Symbol.for("tracepanel.llm.fetch");
|
|
585
|
+
/**
|
|
586
|
+
* fetch(), its calls to a model's API (a POST of JSON with a model to a known endpoint) as LLM call spans: what the
|
|
587
|
+
* AI SDK's providers and other clients of these APIs send. One an SDK's client makes is that call's already.
|
|
588
|
+
*/
|
|
589
|
+
export function llmFetch(fetch) {
|
|
590
|
+
if (fetch[WRAPPED])
|
|
591
|
+
return fetch;
|
|
592
|
+
const wrapped = function (input, init) {
|
|
593
|
+
const plain = () => fetch.call(this, input, init);
|
|
594
|
+
if (!trace.getActiveSpan() || inCall() || typeof init?.body !== "string")
|
|
595
|
+
return plain();
|
|
596
|
+
const method = (init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
597
|
+
const url = urlOf(input);
|
|
598
|
+
const found = method === "POST" && url ? endpoint(url.pathname) : undefined;
|
|
599
|
+
if (!url || !found)
|
|
600
|
+
return plain();
|
|
601
|
+
let body;
|
|
602
|
+
try {
|
|
603
|
+
body = JSON.parse(init.body);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return plain();
|
|
607
|
+
}
|
|
608
|
+
if (typeof get(body, "model") !== "string")
|
|
609
|
+
return plain();
|
|
610
|
+
const call = begin(found, fetchProvider(url, found), url);
|
|
611
|
+
call.describe(body, get(body, "stream") === true);
|
|
612
|
+
return context.with(inside(call), plain).then((response) => {
|
|
613
|
+
if (response.ok)
|
|
614
|
+
call.answered(response);
|
|
615
|
+
else
|
|
616
|
+
call.failed(String(response.status), response.status);
|
|
617
|
+
return response;
|
|
618
|
+
}, (error) => {
|
|
619
|
+
call.thrown(error);
|
|
620
|
+
throw error;
|
|
621
|
+
});
|
|
622
|
+
};
|
|
623
|
+
Object.defineProperty(wrapped, WRAPPED, { value: true });
|
|
624
|
+
return Object.assign(wrapped, fetch);
|
|
625
|
+
}
|
|
626
|
+
/** globalThis.fetch, its LLM calls traced, once per process. */
|
|
627
|
+
export function traceLlmFetch() {
|
|
628
|
+
if (typeof globalThis.fetch !== "function")
|
|
629
|
+
return;
|
|
630
|
+
globalThis.fetch = llmFetch(globalThis.fetch);
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=llm.js.map
|