@respan/instrumentation-vercel 1.0.4 → 1.0.6

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.
@@ -8,603 +8,12 @@
8
8
  * Two-phase enrichment:
9
9
  * - onStart(): Sets RESPAN_LOG_TYPE so the span passes CompositeProcessor filtering
10
10
  * - onEnd(): Full attribute translation (model, messages, tokens, metadata, etc.)
11
- *
12
- * Vercel attrs are preserved (additive enrichment via setDefault, not destructive).
13
- *
14
- * Ported from @respan/exporter-vercel — all exporter features are replicated:
15
- * - Model normalization (Gemini, Claude, DeepSeek, O3-mini)
16
- * - Prompt message parsing (ai.prompt.messages + ai.prompt fallback)
17
- * - Completion message building (ai.response.text, ai.response.object, tool calls)
18
- * - Token count normalization (input/output → prompt/completion)
19
- * - Tool definitions (ai.prompt.tools) and tool choice (ai.prompt.toolChoice)
20
- * - Customer params (ai.telemetry.metadata.customer_* + customer_params JSON)
21
- * - General metadata (ai.telemetry.metadata.* → respan.metadata.*)
22
- * - Stream detection, environment, cost, TTFT, generation time, unit prices
23
- * - Log type detection with operationId + attribute-based fallbacks
24
- */
25
- import { RespanSpanAttributes, RespanLogType } from "@respan/respan-sdk";
26
- import { VERCEL_SPAN_CONFIG, VERCEL_PARENT_SPANS } from "./constants/index.js";
27
- // ── Attribute keys (single source of truth from SDK) ─────────────────────────
28
- const RESPAN_LOG_TYPE = RespanSpanAttributes.RESPAN_LOG_TYPE;
29
- const GEN_AI_REQUEST_MODEL = RespanSpanAttributes.GEN_AI_REQUEST_MODEL;
30
- const GEN_AI_USAGE_PROMPT_TOKENS = RespanSpanAttributes.GEN_AI_USAGE_PROMPT_TOKENS;
31
- const GEN_AI_USAGE_COMPLETION_TOKENS = RespanSpanAttributes.GEN_AI_USAGE_COMPLETION_TOKENS;
32
- const LLM_REQUEST_TYPE = RespanSpanAttributes.LLM_REQUEST_TYPE;
33
- const CUSTOMER_ID = RespanSpanAttributes.RESPAN_CUSTOMER_PARAMS_ID;
34
- const CUSTOMER_EMAIL = RespanSpanAttributes.RESPAN_CUSTOMER_PARAMS_EMAIL;
35
- const CUSTOMER_NAME = RespanSpanAttributes.RESPAN_CUSTOMER_PARAMS_NAME;
36
- const THREAD_ID = RespanSpanAttributes.RESPAN_THREADS_ID;
37
- // RESPAN_SESSION_ID not yet published in @respan/respan-sdk — use wire value directly
38
- const SESSION_ID = "respan.sessions.session_identifier";
39
- const TRACE_GROUP_ID = RespanSpanAttributes.RESPAN_TRACE_GROUP_ID;
40
- const RESPAN_SPAN_TOOLS = RespanSpanAttributes.RESPAN_SPAN_TOOLS;
41
- const RESPAN_METADATA_AGENT_NAME = RespanSpanAttributes.RESPAN_METADATA_AGENT_NAME;
42
- const RESPAN_METADATA_PREFIX = RespanSpanAttributes.RESPAN_METADATA; // "respan.metadata"
43
- /** Build a respan.metadata.<key> attribute name. */
44
- function metadataKey(key) {
45
- return `${RESPAN_METADATA_PREFIX}.${key}`;
46
- }
47
- // Traceloop wire-format keys
48
- const TL_SPAN_KIND = "traceloop.span.kind";
49
- const TL_ENTITY_NAME = "traceloop.entity.name";
50
- const TL_ENTITY_INPUT = "traceloop.entity.input";
51
- const TL_ENTITY_OUTPUT = "traceloop.entity.output";
52
- const TL_ENTITY_PATH = "traceloop.entity.path";
53
- // ── Helpers ──────────────────────────────────────────────────────────────────
54
- function setDefault(attrs, key, value) {
55
- if (attrs[key] === undefined && value !== undefined && value !== null) {
56
- attrs[key] = value;
57
- }
58
- }
59
- function safeJsonStr(value) {
60
- if (value === undefined || value === null)
61
- return "";
62
- if (typeof value === "string")
63
- return value;
64
- try {
65
- return JSON.stringify(value);
66
- }
67
- catch {
68
- return String(value);
69
- }
70
- }
71
- function safeJsonParse(value) {
72
- if (typeof value !== "string")
73
- return value;
74
- try {
75
- return JSON.parse(value);
76
- }
77
- catch {
78
- return value;
79
- }
80
- }
81
- /**
82
- * Detect whether a span is from the Vercel AI SDK.
83
- *
84
- * Primary check: instrumentation scope name === "ai" (set by the Vercel AI SDK).
85
- * Fallback: ai.sdk attribute or ai.* span name.
86
- *
87
- * Does NOT match on gen_ai.* attributes alone — those may come from other
88
- * instrumentations (OpenInference, Traceloop) and must not be stripped.
89
- */
90
- function isVercelAISpan(span) {
91
- // Primary: check OTEL instrumentation scope (most reliable, no false positives)
92
- if (span.instrumentationLibrary?.name === "ai")
93
- return true;
94
- // Fallback: explicit Vercel marker or span name convention
95
- if (span.attributes["ai.sdk"] !== undefined)
96
- return true;
97
- if (span.name.startsWith("ai."))
98
- return true;
99
- return false;
100
- }
101
- // ── Log type detection (with operationId + attribute fallbacks) ──────────────
102
- /**
103
- * Resolve the Respan log type for a Vercel AI SDK span.
104
- * Replicates the full fallback chain from the exporter's parseLogType().
105
- */
106
- function resolveLogType(name, attrs) {
107
- // 1. Direct span name match
108
- const config = VERCEL_SPAN_CONFIG[name];
109
- if (config)
110
- return config.logType;
111
- const parentType = VERCEL_PARENT_SPANS[name];
112
- if (parentType)
113
- return parentType;
114
- // 2. operationId attribute fallback
115
- const operationId = attrs["ai.operationId"]?.toString();
116
- if (operationId) {
117
- const opConfig = VERCEL_SPAN_CONFIG[operationId];
118
- if (opConfig)
119
- return opConfig.logType;
120
- const opParent = VERCEL_PARENT_SPANS[operationId];
121
- if (opParent)
122
- return opParent;
123
- }
124
- // 3. Attribute-based fallback detection (same heuristics as exporter)
125
- if (attrs["ai.embedding"] || attrs["ai.embeddings"] ||
126
- name.includes("embed") || operationId?.includes("embed")) {
127
- return RespanLogType.EMBEDDING;
128
- }
129
- if (attrs["ai.toolCall.id"] || attrs["ai.toolCall.name"] ||
130
- attrs["ai.toolCall.args"] || attrs["ai.toolCall.result"] ||
131
- attrs["ai.response.toolCalls"] ||
132
- name.includes("tool") || operationId?.includes("tool")) {
133
- return RespanLogType.TOOL;
134
- }
135
- if (attrs["ai.agent.id"] ||
136
- name.includes("agent") || operationId?.includes("agent")) {
137
- return RespanLogType.AGENT;
138
- }
139
- if (attrs["ai.workflow.id"] ||
140
- name.includes("workflow") || operationId?.includes("workflow")) {
141
- return RespanLogType.WORKFLOW;
142
- }
143
- if (attrs["ai.transcript"] ||
144
- name.includes("transcript") || operationId?.includes("transcript")) {
145
- return RespanLogType.TRANSCRIPTION;
146
- }
147
- if (attrs["ai.speech"] ||
148
- name.includes("speech") || operationId?.includes("speech")) {
149
- return RespanLogType.SPEECH;
150
- }
151
- // 4. Generation span fallback (doGenerate/doStream)
152
- if (name.includes("doGenerate") || name.includes("doStream")) {
153
- return RespanLogType.TEXT;
154
- }
155
- return RespanLogType.UNKNOWN;
156
- }
157
- // ── Model normalization ──────────────────────────────────────────────────────
158
- /**
159
- * Normalize the model ID from Vercel's ai.model.id to a standard model name.
160
- * Replicates the logic from the existing exporter.
161
- */
162
- function normalizeModel(modelId) {
163
- const model = modelId.toLowerCase();
164
- if (model.includes("gemini-2.0-flash-001"))
165
- return "gemini/gemini-2.0-flash";
166
- if (model.includes("gemini-2.0-pro"))
167
- return "gemini/gemini-2.0-pro-exp-02-05";
168
- if (model.includes("claude-3-5-sonnet"))
169
- return "claude-3-5-sonnet-20241022";
170
- if (model.includes("deepseek"))
171
- return "deepseek/" + model;
172
- if (model.includes("o3-mini"))
173
- return "o3-mini";
174
- return model;
175
- }
176
- // ── Prompt/completion message formatting ─────────────────────────────────────
177
- /**
178
- * Parse ai.prompt.messages into a JSON string suitable for traceloop.entity.input.
179
- * Falls back to ai.prompt as a single user message.
180
11
  */
181
- function formatPromptInput(attrs) {
182
- const messages = attrs["ai.prompt.messages"];
183
- if (messages) {
184
- try {
185
- const parsed = typeof messages === "string" ? JSON.parse(messages) : messages;
186
- return safeJsonStr(parsed);
187
- }
188
- catch {
189
- // fall through
190
- }
191
- }
192
- const prompt = attrs["ai.prompt"];
193
- if (prompt) {
194
- return safeJsonStr([{ role: "user", content: String(prompt) }]);
195
- }
196
- return undefined;
197
- }
198
- /**
199
- * Build completion output from ai.response.text, ai.response.object, and tool calls.
200
- * Also includes tool result messages when present (for tool call spans).
201
- */
202
- function formatCompletionOutput(attrs) {
203
- let content = "";
204
- if (attrs["ai.response.object"]) {
205
- try {
206
- const rawObject = attrs["ai.response.object"];
207
- const parsed = typeof rawObject === "string" ? JSON.parse(rawObject) : rawObject;
208
- // generateObject returns the object directly (no `response` wrapper).
209
- // Prefer known wrappers when present, otherwise serialize the object itself.
210
- const normalized = parsed?.response ?? parsed?.object ?? parsed?.output ?? parsed?.result ?? parsed;
211
- content = safeJsonStr(normalized);
212
- }
213
- catch {
214
- content = String(attrs["ai.response.text"] ?? "");
215
- }
216
- }
217
- else {
218
- content = String(attrs["ai.response.text"] ?? "");
219
- }
220
- // Build assistant message with optional tool calls
221
- const toolCalls = parseToolCalls(attrs);
222
- // Bail only when there's no text AND no tool calls
223
- if (!content && (!toolCalls || toolCalls.length === 0))
224
- return undefined;
225
- const message = { role: "assistant", content };
226
- if (toolCalls && toolCalls.length > 0) {
227
- message.tool_calls = toolCalls;
228
- }
229
- // Include tool result as a separate message if present
230
- const messages = [message];
231
- if (attrs["ai.toolCall.result"]) {
232
- messages.push({
233
- role: "tool",
234
- tool_call_id: String(attrs["ai.toolCall.id"] || ""),
235
- content: String(attrs["ai.toolCall.result"] || ""),
236
- });
237
- }
238
- return safeJsonStr(messages.length === 1 ? message : messages);
239
- }
240
- /**
241
- * Parse tool call data from various Vercel AI SDK attribute formats.
242
- */
243
- function parseToolCalls(attrs) {
244
- // Try array formats first
245
- for (const key of ["ai.response.toolCalls", "ai.toolCall", "ai.toolCalls"]) {
246
- if (!attrs[key])
247
- continue;
248
- try {
249
- const parsed = typeof attrs[key] === "string" ? JSON.parse(attrs[key]) : attrs[key];
250
- const calls = Array.isArray(parsed) ? parsed : [parsed];
251
- return calls.map((call) => {
252
- if (!call || typeof call !== "object")
253
- return { type: "function" };
254
- const result = { ...call };
255
- if (!result.type)
256
- result.type = "function";
257
- if (!result.id && (result.toolCallId || result.tool_call_id)) {
258
- result.id = result.toolCallId || result.tool_call_id;
259
- }
260
- return result;
261
- });
262
- }
263
- catch {
264
- continue;
265
- }
266
- }
267
- // Try individual tool call attributes
268
- if (attrs["ai.toolCall.id"] || attrs["ai.toolCall.name"] || attrs["ai.toolCall.args"]) {
269
- const toolCall = { type: "function" };
270
- for (const [key, value] of Object.entries(attrs)) {
271
- if (key.startsWith("ai.toolCall.")) {
272
- toolCall[key.replace("ai.toolCall.", "")] = value;
273
- }
274
- }
275
- return [toolCall];
276
- }
277
- return undefined;
278
- }
279
- /**
280
- * Format tool call span input/output for traceloop.entity.input/output.
281
- */
282
- function formatToolInput(attrs) {
283
- const name = attrs["ai.toolCall.name"];
284
- const args = attrs["ai.toolCall.args"];
285
- if (!name && !args)
286
- return undefined;
287
- const input = {};
288
- if (name)
289
- input.name = name;
290
- if (args) {
291
- input.args = typeof args === "string" ? safeJsonParse(args) : args;
292
- }
293
- return safeJsonStr(input);
294
- }
295
- function formatToolOutput(attrs) {
296
- const result = attrs["ai.toolCall.result"];
297
- if (result === undefined)
298
- return undefined;
299
- return safeJsonStr(typeof result === "string" ? safeJsonParse(result) : result);
300
- }
301
- // ── Tools & tool choice (from exporter's parseTools / parseToolChoice) ───────
302
- /**
303
- * Parse ai.prompt.tools into a normalized tool definition array.
304
- * Accepts both nested ({type:"function",function:{...}}) and flat shapes.
305
- */
306
- function parseTools(attrs) {
307
- try {
308
- const tools = attrs["ai.prompt.tools"];
309
- if (!tools)
310
- return undefined;
311
- const raw = Array.isArray(tools) ? tools : [tools];
312
- const parsed = raw
313
- .map((tool) => {
314
- try {
315
- return typeof tool === "string" ? JSON.parse(tool) : tool;
316
- }
317
- catch {
318
- return undefined;
319
- }
320
- })
321
- .filter(Boolean)
322
- .map((tool) => {
323
- // Accept both nested and flat shapes; normalize to nested
324
- if (tool && tool.type === "function") {
325
- if (tool.function && typeof tool.function === "object") {
326
- // Already nested — move top-level inputSchema into function.parameters
327
- // (Vercel AI SDK puts inputSchema at the top level, backend expects function.parameters)
328
- if (tool.inputSchema && !tool.function.parameters) {
329
- const { inputSchema, ...rest } = tool;
330
- return { ...rest, function: { ...tool.function, parameters: inputSchema } };
331
- }
332
- return tool;
333
- }
334
- const { name, description, parameters, inputSchema, ...rest } = tool;
335
- const params = parameters ?? inputSchema;
336
- return {
337
- ...rest,
338
- type: "function",
339
- function: {
340
- name,
341
- ...(description ? { description } : {}),
342
- ...(params ? { parameters: params } : {}),
343
- },
344
- };
345
- }
346
- return tool;
347
- });
348
- if (parsed.length === 0)
349
- return undefined;
350
- return safeJsonStr(parsed);
351
- }
352
- catch {
353
- return undefined;
354
- }
355
- }
356
- /**
357
- * Parse tool choice from Vercel's ai.prompt.toolChoice attribute.
358
- */
359
- function parseToolChoice(attrs) {
360
- try {
361
- const toolChoice = attrs["ai.prompt.toolChoice"];
362
- if (!toolChoice)
363
- return undefined;
364
- const parsed = typeof toolChoice === "string" ? JSON.parse(toolChoice) : toolChoice;
365
- if (parsed.function?.name) {
366
- return safeJsonStr({
367
- type: String(parsed.type),
368
- function: { name: String(parsed.function.name) },
369
- });
370
- }
371
- return safeJsonStr({ type: String(parsed.type) });
372
- }
373
- catch {
374
- return undefined;
375
- }
376
- }
377
- // ── Metadata / customer params ───────────────────────────────────────────────
378
- /**
379
- * Extract ai.telemetry.metadata.* and map customer/thread params to Respan attrs.
380
- * Also handles prompt_unit_price, completion_unit_price, and span_type metadata.
381
- */
382
- function enrichMetadata(attrs, spanName) {
383
- for (const [key, value] of Object.entries(attrs)) {
384
- if (!key.startsWith("ai.telemetry.metadata."))
385
- continue;
386
- const cleanKey = key.slice("ai.telemetry.metadata.".length);
387
- // Map well-known keys to Respan span attributes
388
- switch (cleanKey) {
389
- case "customer_identifier":
390
- setDefault(attrs, CUSTOMER_ID, String(value));
391
- break;
392
- case "customer_email":
393
- setDefault(attrs, CUSTOMER_EMAIL, String(value));
394
- break;
395
- case "customer_name":
396
- setDefault(attrs, CUSTOMER_NAME, String(value));
397
- break;
398
- case "session_identifier":
399
- setDefault(attrs, SESSION_ID, String(value));
400
- break;
401
- case "thread_identifier":
402
- setDefault(attrs, THREAD_ID, String(value));
403
- break;
404
- case "trace_group_identifier":
405
- setDefault(attrs, TRACE_GROUP_ID, String(value));
406
- break;
407
- case "customer_params": {
408
- // customer_params can be a JSON object with all three fields
409
- try {
410
- const parsed = typeof value === "string" ? JSON.parse(value) : value;
411
- if (parsed?.customer_identifier)
412
- setDefault(attrs, CUSTOMER_ID, parsed.customer_identifier);
413
- if (parsed?.customer_email)
414
- setDefault(attrs, CUSTOMER_EMAIL, parsed.customer_email);
415
- if (parsed?.customer_name)
416
- setDefault(attrs, CUSTOMER_NAME, parsed.customer_name);
417
- }
418
- catch {
419
- // ignore
420
- }
421
- break;
422
- }
423
- case "prompt_unit_price":
424
- setDefault(attrs, metadataKey("prompt_unit_price"), String(value));
425
- break;
426
- case "completion_unit_price":
427
- setDefault(attrs, metadataKey("completion_unit_price"), String(value));
428
- break;
429
- case "userId":
430
- // userId is a fallback for customer_identifier (backward compat with exporter)
431
- setDefault(attrs, CUSTOMER_ID, String(value));
432
- setDefault(attrs, metadataKey(cleanKey), String(value ?? ""));
433
- break;
434
- default:
435
- // All other metadata → respan.metadata.<key>
436
- setDefault(attrs, metadataKey(cleanKey), String(value ?? ""));
437
- break;
438
- }
439
- }
440
- }
441
- // ── Token count normalization ────────────────────────────────────────────────
442
- function enrichTokens(attrs) {
443
- // Vercel AI SDK may use gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
444
- // Respan backend expects gen_ai.usage.prompt_tokens / gen_ai.usage.completion_tokens
445
- const inputTokens = attrs["gen_ai.usage.input_tokens"] ??
446
- attrs["gen_ai.usage.prompt_tokens"];
447
- const outputTokens = attrs["gen_ai.usage.output_tokens"] ??
448
- attrs["gen_ai.usage.completion_tokens"];
449
- if (inputTokens !== undefined) {
450
- setDefault(attrs, GEN_AI_USAGE_PROMPT_TOKENS, Number(inputTokens));
451
- }
452
- if (outputTokens !== undefined) {
453
- setDefault(attrs, GEN_AI_USAGE_COMPLETION_TOKENS, Number(outputTokens));
454
- }
455
- }
456
- // ── Performance / cost metrics ───────────────────────────────────────────────
457
- /**
458
- * Enrich performance and cost attributes that the exporter handled explicitly.
459
- * These are Vercel-specific attrs that the backend needs in standard locations.
460
- */
461
- function enrichPerformanceMetrics(attrs, spanName) {
462
- // Stream detection from span name
463
- setDefault(attrs, metadataKey("stream"), String(spanName.includes("doStream")));
464
- // Time to first token from ai.response.msToFinish (Vercel-specific)
465
- const msToFinish = attrs["ai.response.msToFinish"];
466
- if (msToFinish !== undefined) {
467
- setDefault(attrs, metadataKey("time_to_first_token"), String(Number(msToFinish) / 1000));
468
- }
469
- // Cost (gen_ai.usage.cost is standard but ensure it's present)
470
- const cost = attrs["gen_ai.usage.cost"];
471
- if (cost !== undefined) {
472
- setDefault(attrs, metadataKey("cost"), String(cost));
473
- }
474
- // TTFT (gen_ai.usage.ttft)
475
- const ttft = attrs["gen_ai.usage.ttft"];
476
- if (ttft !== undefined) {
477
- setDefault(attrs, metadataKey("ttft"), String(ttft));
478
- }
479
- // Generation time
480
- const genTime = attrs["gen_ai.usage.generation_time"];
481
- if (genTime !== undefined) {
482
- setDefault(attrs, metadataKey("generation_time"), String(genTime));
483
- }
484
- // Warnings
485
- const warnings = attrs["gen_ai.usage.warnings"];
486
- if (warnings !== undefined) {
487
- setDefault(attrs, metadataKey("warnings"), String(warnings));
488
- }
489
- // Response type (text/json_schema/json_object)
490
- const type = attrs["gen_ai.usage.type"];
491
- if (type !== undefined) {
492
- setDefault(attrs, metadataKey("type"), String(type));
493
- }
494
- }
495
- // ── Cleanup: strip redundant Vercel attrs after translation ──────────────────
496
- /**
497
- * Vercel AI SDK attributes that have been translated to Traceloop/GenAI/Respan
498
- * equivalents. These are deleted after translation to keep spans clean.
499
- */
500
- const VERCEL_ATTRS_TO_STRIP = [
501
- // ── Vercel AI SDK attrs (translated to Traceloop/GenAI equivalents) ────
502
- // Model (translated to gen_ai.request.model)
503
- "ai.model.id",
504
- "ai.model.provider",
505
- "ai.response.model",
506
- // Prompt/completion (translated to traceloop.entity.input/output)
507
- "ai.prompt",
508
- "ai.prompt.messages",
509
- "ai.prompt.format",
510
- "ai.response.text",
511
- "ai.response.object",
512
- // Tokens — old names (v5) + new names (v6)
513
- "ai.usage.promptTokens",
514
- "ai.usage.completionTokens",
515
- "ai.usage.inputTokens",
516
- "ai.usage.outputTokens",
517
- "ai.usage.totalTokens",
518
- "ai.usage.reasoningTokens",
519
- "ai.usage.cachedInputTokens",
520
- // Response metadata (redundant with standard OTEL/GenAI attrs)
521
- "ai.response.finishReason",
522
- "ai.response.id",
523
- "ai.response.timestamp",
524
- "ai.response.providerMetadata",
525
- "ai.response.msToFinish",
526
- "ai.response.msToFirstChunk",
527
- "ai.response.avgOutputTokensPerSecond",
528
- "ai.response.avgCompletionTokensPerSecond",
529
- // Request metadata
530
- "ai.request.headers.user-agent",
531
- // Tool choice (translated to respan.metadata.tool_choice)
532
- "ai.prompt.toolChoice",
533
- // SDK internals (no user value)
534
- "ai.operationId",
535
- "ai.settings.maxRetries",
536
- "ai.settings.maxSteps",
537
- "ai.sdk",
538
- "operation.name",
539
- // Tool calls (translated to traceloop.entity.input/output for tool spans)
540
- "ai.toolCall.id",
541
- "ai.toolCall.name",
542
- "ai.toolCall.args",
543
- "ai.toolCall.result",
544
- "ai.response.toolCalls",
545
- // GenAI duplicates (already consumed by backend as top-level fields)
546
- "gen_ai.response.finish_reasons",
547
- "gen_ai.response.id",
548
- "gen_ai.usage.input_tokens",
549
- "gen_ai.usage.output_tokens",
550
- "gen_ai.system",
551
- // ── Traceloop routing attrs (Vercel-specific, not user-facing) ──────────
552
- // Keep traceloop.span.kind and respan.entity.log_type — backend needs them.
553
- // Keep respan.environment — may be set by user via propagateAttributes().
554
- "traceloop.entity.name",
555
- "traceloop.entity.path",
556
- // ── OTEL resource / process noise (no user value in metadata) ──────────
557
- "service.name",
558
- "telemetry.sdk.language",
559
- "telemetry.sdk.name",
560
- "telemetry.sdk.version",
561
- "process.pid",
562
- "process.executable.name",
563
- "process.executable.path",
564
- "process.command_args",
565
- "process.runtime.version",
566
- "process.runtime.name",
567
- "process.runtime.description",
568
- "process.command",
569
- "process.owner",
570
- "host.name",
571
- "host.arch",
572
- "host.id",
573
- "otel.scope.name",
574
- "otel.scope.version",
575
- // ── Next.js auto-instrumentation noise ─────────────────────────────────
576
- "next.span_name",
577
- "next.span_type",
578
- "http.url",
579
- "http.method",
580
- "net.peer.name",
581
- ];
582
- /**
583
- * Remove redundant Vercel AI SDK attributes after translation.
584
- * Also strips ai.telemetry.metadata.* keys that have been mapped to respan.* attrs.
585
- */
586
- function stripRedundantAttrs(attrs) {
587
- for (const key of VERCEL_ATTRS_TO_STRIP) {
588
- delete attrs[key];
589
- }
590
- for (const key of Object.keys(attrs)) {
591
- // Strip ai.telemetry.metadata.* (already mapped to respan.metadata.* / respan.customer_params.*)
592
- if (key.startsWith("ai.telemetry.metadata.")) {
593
- delete attrs[key];
594
- continue;
595
- }
596
- // Strip ai.usage.*Details.* (e.g. inputTokenDetails.noCacheTokens, outputTokenDetails.textTokens)
597
- if (key.startsWith("ai.usage.") && key.includes("Details.")) {
598
- delete attrs[key];
599
- continue;
600
- }
601
- }
602
- // Strip ai.prompt.tools (translated to respan.span.tools)
603
- if (attrs["ai.prompt.tools"] !== undefined) {
604
- delete attrs["ai.prompt.tools"];
605
- }
606
- }
607
- // ── Main processor ───────────────────────────────────────────────────────────
12
+ import { RespanLogType } from "@respan/respan-sdk";
13
+ import { VERCEL_PARENT_SPANS, VERCEL_SPAN_CONFIG } from "./constants/index.js";
14
+ import { formatCompletionOutput, formatPromptInput, formatToolInput, formatToolOutput, parseToolChoice, parseToolsValue } from "./_translator/messages.js";
15
+ import { AI_AGENT_ID, AI_MODEL_ID, AI_PREFIX, GEN_AI_REQUEST_MODEL, LLM_REQUEST_TYPE, RESPAN_LOG_TYPE, RESPAN_METADATA_AGENT_NAME, RESPAN_SPAN_TOOLS, TL_ENTITY_INPUT, TL_ENTITY_OUTPUT, TL_REQUEST_FUNCTIONS, isVercelAISpan, metadataKey, normalizeModel, resolveLogType, safeJsonStr, setDefault, } from "./_translator/shared.js";
16
+ import { enrichMetadata, enrichPerformanceMetrics, enrichTokens, stripRedundantAttrs } from "./_translator/span-enrichment.js";
608
17
  /**
609
18
  * SpanProcessor that translates Vercel AI SDK attributes to Traceloop/OpenLLMetry.
610
19
  *
@@ -614,99 +23,95 @@ function stripRedundantAttrs(attrs) {
614
23
  */
615
24
  export class VercelAITranslator {
616
25
  onStart(span, _parentContext) {
617
- // Cast to access attributes (Span interface doesn't expose them, but the impl does)
618
- const s = span;
619
- const name = s.name ?? "";
620
- if (!name.startsWith("ai."))
26
+ const writableSpan = span;
27
+ const name = writableSpan.name ?? "";
28
+ if (!name.startsWith(AI_PREFIX)) {
621
29
  return;
622
- // Set RESPAN_LOG_TYPE early so CompositeProcessor accepts this span
30
+ }
623
31
  const config = VERCEL_SPAN_CONFIG[name];
624
32
  if (config) {
625
- s.setAttribute(RESPAN_LOG_TYPE, config.logType);
626
- }
627
- else if (VERCEL_PARENT_SPANS[name] !== undefined) {
628
- s.setAttribute(RESPAN_LOG_TYPE, VERCEL_PARENT_SPANS[name]);
33
+ writableSpan.setAttribute(RESPAN_LOG_TYPE, config.logType);
34
+ return;
629
35
  }
630
- else {
631
- // Unknown ai.* span — use full fallback detection
632
- // At onStart, attributes may be sparse, so set a generic type.
633
- // The precise type will be resolved in onEnd() with full attributes.
634
- s.setAttribute(RESPAN_LOG_TYPE, RespanLogType.TASK);
36
+ const parentLogType = VERCEL_PARENT_SPANS[name];
37
+ if (parentLogType !== undefined) {
38
+ writableSpan.setAttribute(RESPAN_LOG_TYPE, parentLogType);
39
+ return;
635
40
  }
41
+ writableSpan.setAttribute(RESPAN_LOG_TYPE, RespanLogType.TASK);
636
42
  }
637
43
  onEnd(span) {
638
44
  const attrs = span.attributes;
639
- if (!attrs)
640
- return;
641
- if (!isVercelAISpan(span))
45
+ if (!attrs || !isVercelAISpan(span)) {
642
46
  return;
47
+ }
643
48
  const name = span.name;
644
49
  const config = VERCEL_SPAN_CONFIG[name];
645
50
  const parentLogType = VERCEL_PARENT_SPANS[name];
646
- // Resolve the log type using full fallback chain (name → operationId → attributes)
647
51
  const logType = resolveLogType(name, attrs);
648
- // ── Always: metadata, customer params, environment ────────────────────
649
- enrichMetadata(attrs, name);
650
- // ── Parent wrapper spans: minimal enrichment only ─────────────────────
52
+ enrichMetadata(attrs);
651
53
  if (parentLogType !== undefined && !config) {
652
54
  setDefault(attrs, RESPAN_LOG_TYPE, logType);
653
55
  stripRedundantAttrs(attrs);
654
56
  return;
655
57
  }
656
- // ── Detailed / leaf spans: full enrichment ────────────────────────────
657
- // Update RESPAN_LOG_TYPE with the resolved type (may be more accurate than onStart)
658
58
  attrs[RESPAN_LOG_TYPE] = logType;
659
59
  if (config) {
660
- setDefault(attrs, TL_SPAN_KIND, config.kind);
661
- // LLM-specific enrichment
60
+ // Do NOT set traceloop.span.kind for auto-emitted Vercel SDK spans.
61
+ // In the Respan composite processor `traceloop.span.kind` is reserved
62
+ // for user-decorated spans (withWorkflow / withTask / withAgent) and
63
+ // setting it on auto spans (a) flattens the parent/child tree and
64
+ // (b) causes LLM detail spans (doGenerate / doStream) to be classified
65
+ // as "task" instead of LLM in the backend. The respan.entity.log_type
66
+ // attribute (set above) carries the correct type for ingestion.
67
+ // Matches the patterns in respan-instrumentation-openinference (see
68
+ // _translator.ts:500) and respan-instrumentation-openai-agents
69
+ // (see _otel_emitter.ts:398).
662
70
  if (config.isLLM) {
663
71
  setDefault(attrs, LLM_REQUEST_TYPE, RespanLogType.CHAT);
664
- // Model
665
- const modelId = attrs["ai.model.id"];
72
+ const modelId = attrs[AI_MODEL_ID];
666
73
  if (modelId) {
667
74
  setDefault(attrs, GEN_AI_REQUEST_MODEL, normalizeModel(String(modelId)));
668
75
  }
669
- // Prompt messages → entity input
670
76
  const input = formatPromptInput(attrs);
671
- if (input)
77
+ if (input) {
672
78
  setDefault(attrs, TL_ENTITY_INPUT, input);
673
- // Completion → entity output
79
+ }
674
80
  const output = formatCompletionOutput(attrs);
675
- if (output)
81
+ if (output) {
676
82
  setDefault(attrs, TL_ENTITY_OUTPUT, output);
677
- // Token counts
83
+ }
678
84
  enrichTokens(attrs);
679
- // Tool definitions
680
- const tools = parseTools(attrs);
681
- if (tools)
682
- setDefault(attrs, RESPAN_SPAN_TOOLS, tools);
683
- // Tool choice
85
+ const toolsValue = parseToolsValue(attrs);
86
+ if (toolsValue) {
87
+ const tools = safeJsonStr(toolsValue);
88
+ attrs[RESPAN_SPAN_TOOLS] = tools;
89
+ attrs[TL_REQUEST_FUNCTIONS] = tools;
90
+ }
684
91
  const toolChoice = parseToolChoice(attrs);
685
- if (toolChoice)
92
+ if (toolChoice) {
686
93
  setDefault(attrs, metadataKey("tool_choice"), toolChoice);
687
- // Performance metrics (stream, TTFT, cost, etc.)
94
+ }
688
95
  enrichPerformanceMetrics(attrs, name);
689
96
  }
690
- // Tool call spans
691
97
  if (config.logType === RespanLogType.TOOL || logType === RespanLogType.TOOL) {
692
98
  const toolInput = formatToolInput(attrs);
693
- if (toolInput)
99
+ if (toolInput) {
694
100
  setDefault(attrs, TL_ENTITY_INPUT, toolInput);
101
+ }
695
102
  const toolOutput = formatToolOutput(attrs);
696
- if (toolOutput)
103
+ if (toolOutput) {
697
104
  setDefault(attrs, TL_ENTITY_OUTPUT, toolOutput);
105
+ }
698
106
  }
699
- // Agent spans
700
107
  if (config.logType === RespanLogType.AGENT || logType === RespanLogType.AGENT) {
701
- const agentName = attrs["ai.agent.name"] ?? attrs["ai.agent.id"] ?? name;
108
+ const agentName = attrs["ai.agent.name"] ?? attrs[AI_AGENT_ID] ?? name;
702
109
  setDefault(attrs, RESPAN_METADATA_AGENT_NAME, String(agentName));
703
110
  }
704
111
  }
705
112
  else {
706
- // Unknown ai.* span — enrich with fallback-resolved type
707
- // If fallback detected it as an LLM span, add model + tokens
708
113
  if (logType === RespanLogType.TEXT || logType === RespanLogType.EMBEDDING) {
709
- const modelId = attrs["ai.model.id"];
114
+ const modelId = attrs[AI_MODEL_ID];
710
115
  if (modelId) {
711
116
  setDefault(attrs, GEN_AI_REQUEST_MODEL, normalizeModel(String(modelId)));
712
117
  }
@@ -714,28 +119,34 @@ export class VercelAITranslator {
714
119
  if (logType === RespanLogType.TEXT) {
715
120
  setDefault(attrs, LLM_REQUEST_TYPE, RespanLogType.CHAT);
716
121
  const input = formatPromptInput(attrs);
717
- if (input)
122
+ if (input) {
718
123
  setDefault(attrs, TL_ENTITY_INPUT, input);
124
+ }
719
125
  const output = formatCompletionOutput(attrs);
720
- if (output)
126
+ if (output) {
721
127
  setDefault(attrs, TL_ENTITY_OUTPUT, output);
128
+ }
722
129
  enrichPerformanceMetrics(attrs, name);
723
130
  }
724
131
  }
725
- // If fallback detected tool, add tool input/output
726
132
  if (logType === RespanLogType.TOOL) {
727
133
  const toolInput = formatToolInput(attrs);
728
- if (toolInput)
134
+ if (toolInput) {
729
135
  setDefault(attrs, TL_ENTITY_INPUT, toolInput);
136
+ }
730
137
  const toolOutput = formatToolOutput(attrs);
731
- if (toolOutput)
138
+ if (toolOutput) {
732
139
  setDefault(attrs, TL_ENTITY_OUTPUT, toolOutput);
140
+ }
733
141
  }
734
142
  }
735
- // ── Cleanup: remove redundant Vercel attrs that have been translated ──
736
143
  stripRedundantAttrs(attrs);
737
144
  }
738
- async shutdown() { }
739
- async forceFlush() { }
145
+ forceFlush() {
146
+ return Promise.resolve();
147
+ }
148
+ shutdown() {
149
+ return Promise.resolve();
150
+ }
740
151
  }
741
152
  //# sourceMappingURL=_translator.js.map