@node-llm/monitor-otel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * NodeLLM SpanProcessor for OpenTelemetry
3
+ *
4
+ * This SpanProcessor intercepts OpenTelemetry spans emitted by the Vercel AI SDK
5
+ * (and other OTel-instrumented LLM libraries) and converts them to NodeLLM Monitor events.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
10
+ * import { NodeLLMSpanProcessor } from "@node-llm/monitor/otel";
11
+ * import { PrismaAdapter } from "@node-llm/monitor/adapters/prisma";
12
+ *
13
+ * const store = new PrismaAdapter(prisma);
14
+ * const provider = new NodeTracerProvider();
15
+ * provider.addSpanProcessor(new NodeLLMSpanProcessor(store));
16
+ * provider.register();
17
+ *
18
+ * // Now Vercel AI SDK's experimental_telemetry will flow to node-llm-monitor
19
+ * await generateText({
20
+ * model: openai("gpt-4o"),
21
+ * prompt: "Hello",
22
+ * experimental_telemetry: { isEnabled: true }
23
+ * });
24
+ * ```
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import type { MonitoringStore, MonitoringEvent } from "@node-llm/monitor";
29
+ import type {
30
+ OTelReadableSpan,
31
+ OTelSpan,
32
+ OTelContext,
33
+ OTelSpanProcessor,
34
+ NodeLLMSpanProcessorOptions
35
+ } from "./types.js";
36
+ import {
37
+ isAISpan,
38
+ isTopLevelAISpan,
39
+ getOperationType,
40
+ extractAIAttributes,
41
+ hrTimeToDate,
42
+ calculateDurationMs,
43
+ extractProvider,
44
+ extractModel,
45
+ mapStatusToEventType,
46
+ generateRequestId,
47
+ extractSessionId,
48
+ parseJsonAttribute
49
+ } from "./utils.js";
50
+ import { estimateCost } from "@node-llm/monitor";
51
+
52
+ /**
53
+ * OpenTelemetry SpanProcessor that routes AI spans to NodeLLM Monitor storage.
54
+ *
55
+ * This processor:
56
+ * 1. Filters for AI-related spans (ai.generateText, ai.streamText, etc.)
57
+ * 2. Extracts LLM-specific attributes (model, tokens, cost, etc.)
58
+ * 3. Converts to MonitoringEvent format
59
+ * 4. Saves to the configured MonitoringStore
60
+ */
61
+ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
62
+ private readonly store: MonitoringStore;
63
+ private readonly options: Required<Pick<NodeLLMSpanProcessorOptions, "captureContent">> &
64
+ NodeLLMSpanProcessorOptions;
65
+ private pendingWrites: Promise<void>[] = [];
66
+
67
+ constructor(store: MonitoringStore, options: NodeLLMSpanProcessorOptions = {}) {
68
+ this.store = store;
69
+ this.options = {
70
+ captureContent: true,
71
+ ...options
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Called when a span starts. We don't need to do anything here
77
+ * since we process spans on completion.
78
+ */
79
+ onStart(_span: OTelSpan, _parentContext: OTelContext): void {
80
+ // No-op: We process spans when they end
81
+ }
82
+
83
+ /**
84
+ * Called when a span ends. This is where we extract data and save events.
85
+ */
86
+ onEnd(span: OTelReadableSpan): void {
87
+ // Quick filter: only process AI spans
88
+ if (!isAISpan(span)) {
89
+ return;
90
+ }
91
+
92
+ // Apply custom filter if provided
93
+ if (this.options.filter && !this.options.filter(span)) {
94
+ return;
95
+ }
96
+
97
+ // Process only top-level spans to avoid duplicates
98
+ // (doGenerate/doStream contain the detailed info, but top-level has aggregated data)
99
+ if (!isTopLevelAISpan(span)) {
100
+ return;
101
+ }
102
+
103
+ // Convert and save asynchronously
104
+ const writePromise = this.processSpan(span).catch((error) => {
105
+ if (this.options.onError) {
106
+ this.options.onError(error as Error, span);
107
+ } else {
108
+ console.error("[NodeLLMSpanProcessor] Error processing span:", error);
109
+ }
110
+ });
111
+
112
+ this.pendingWrites.push(writePromise);
113
+ }
114
+
115
+ /**
116
+ * Process a single span and save as MonitoringEvent
117
+ */
118
+ private async processSpan(span: OTelReadableSpan): Promise<void> {
119
+ const event = this.spanToEvent(span);
120
+
121
+ // Apply custom transform if provided
122
+ const finalEvent = this.options.transform ? this.options.transform(event, span) : event;
123
+
124
+ await this.store.saveEvent(finalEvent);
125
+ }
126
+
127
+ /**
128
+ * Convert an OTel span to a MonitoringEvent
129
+ */
130
+ private spanToEvent(span: OTelReadableSpan): MonitoringEvent {
131
+ const attrs = extractAIAttributes(span);
132
+ const operationType = getOperationType(span);
133
+ const provider = extractProvider(attrs);
134
+ const model = extractModel(attrs);
135
+ const duration = calculateDurationMs(span.startTime, span.endTime);
136
+
137
+ // Calculate token counts (prefer AI SDK attributes, fallback to GenAI conventions)
138
+ const promptTokens = attrs.promptTokens ?? attrs.genAiInputTokens ?? 0;
139
+ const completionTokens = attrs.completionTokens ?? attrs.genAiOutputTokens ?? 0;
140
+
141
+ // Estimate cost
142
+ const cost = estimateCost(provider, model, promptTokens, completionTokens);
143
+
144
+ // Extract request ID
145
+ const requestId = this.options.sessionIdExtractor
146
+ ? generateRequestId(span)
147
+ : generateRequestId(span);
148
+
149
+ // Extract session ID
150
+ const sessionId = this.options.sessionIdExtractor
151
+ ? this.options.sessionIdExtractor(span)
152
+ : extractSessionId(span);
153
+
154
+ // Build payload
155
+ const payload: Record<string, unknown> = {
156
+ operationType,
157
+ functionId: attrs.functionId,
158
+ finishReason: attrs.responseFinishReason,
159
+ usage: {
160
+ promptTokens,
161
+ completionTokens,
162
+ totalTokens: promptTokens + completionTokens
163
+ },
164
+ // Streaming metrics
165
+ ...(attrs.msToFirstChunk !== undefined && {
166
+ msToFirstChunk: attrs.msToFirstChunk
167
+ }),
168
+ ...(attrs.msToFinish !== undefined && { msToFinish: attrs.msToFinish }),
169
+ ...(attrs.avgCompletionTokensPerSecond !== undefined && {
170
+ avgCompletionTokensPerSecond: attrs.avgCompletionTokensPerSecond
171
+ }),
172
+ // Custom metadata
173
+ ...(attrs.metadata && { metadata: attrs.metadata }),
174
+ // OpenTelemetry trace context
175
+ otel: {
176
+ traceId: span.spanContext().traceId,
177
+ spanId: span.spanContext().spanId,
178
+ parentSpanId: span.parentSpanId
179
+ }
180
+ };
181
+
182
+ // Add content if enabled
183
+ if (this.options.captureContent) {
184
+ if (attrs.prompt) {
185
+ payload.prompt = attrs.prompt;
186
+ }
187
+ if (attrs.promptMessages) {
188
+ payload.messages = parseJsonAttribute(attrs.promptMessages);
189
+ }
190
+ if (attrs.responseText) {
191
+ payload.result = attrs.responseText;
192
+ }
193
+ if (attrs.responseObject) {
194
+ payload.object = parseJsonAttribute(attrs.responseObject);
195
+ }
196
+ if (attrs.responseToolCalls) {
197
+ payload.toolCalls = parseJsonAttribute(attrs.responseToolCalls);
198
+ }
199
+ }
200
+
201
+ // Tool call specific payload
202
+ if (operationType === "ai.toolCall") {
203
+ payload.tool = {
204
+ name: attrs.toolCallName,
205
+ id: attrs.toolCallId,
206
+ ...(this.options.captureContent && {
207
+ args: parseJsonAttribute(attrs.toolCallArgs),
208
+ result: parseJsonAttribute(attrs.toolCallResult)
209
+ })
210
+ };
211
+ }
212
+
213
+ // Handle errors
214
+ if (span.status.code === 2) {
215
+ payload.error = span.status.message || "Unknown error";
216
+ }
217
+
218
+ return {
219
+ id: randomUUID(),
220
+ eventType: mapStatusToEventType(span.status.code, operationType),
221
+ requestId,
222
+ sessionId,
223
+ time: hrTimeToDate(span.startTime),
224
+ duration,
225
+ cost,
226
+ payload,
227
+ createdAt: new Date(),
228
+ provider,
229
+ model
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Flush all pending writes
235
+ */
236
+ async forceFlush(): Promise<void> {
237
+ await Promise.all(this.pendingWrites);
238
+ this.pendingWrites = [];
239
+ }
240
+
241
+ /**
242
+ * Shutdown the processor
243
+ */
244
+ async shutdown(): Promise<void> {
245
+ await this.forceFlush();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Factory function to create a SpanProcessor with simpler API
251
+ */
252
+ export function createOTelProcessor(
253
+ store: MonitoringStore,
254
+ options?: NodeLLMSpanProcessorOptions
255
+ ): NodeLLMSpanProcessor {
256
+ return new NodeLLMSpanProcessor(store, options);
257
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './NodeLLMSpanProcessor.js';
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * OpenTelemetry Types for NodeLLM Monitor
3
+ *
4
+ * These interfaces mirror the OpenTelemetry API to avoid hard dependencies.
5
+ * Users must provide their own @opentelemetry/api and @opentelemetry/sdk-trace-base.
6
+ */
7
+
8
+ import type {
9
+ Attributes,
10
+ AttributeValue as OTelAttributeValue,
11
+ Context,
12
+ Span,
13
+ SpanStatus
14
+ } from "@opentelemetry/api";
15
+ import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
16
+
17
+ /**
18
+ * Minimal Span interface matching OpenTelemetry ReadableSpan
19
+ */
20
+ export type OTelReadableSpan = ReadableSpan;
21
+ export type OTelSpan = Span;
22
+ export type OTelContext = Context;
23
+ export type OTelAttributes = Attributes;
24
+ export type AttributeValue = OTelAttributeValue;
25
+
26
+ export interface SpanEvent {
27
+ name: string;
28
+ time: [number, number];
29
+ attributes?: Attributes;
30
+ }
31
+
32
+ /**
33
+ * SpanProcessor interface matching OpenTelemetry SDK
34
+ */
35
+ export type OTelSpanProcessor = SpanProcessor;
36
+
37
+ /**
38
+ * Vercel AI SDK span operation types
39
+ */
40
+ export type AIOperationType =
41
+ | "ai.generateText"
42
+ | "ai.generateText.doGenerate"
43
+ | "ai.streamText"
44
+ | "ai.streamText.doStream"
45
+ | "ai.generateObject"
46
+ | "ai.generateObject.doGenerate"
47
+ | "ai.streamObject"
48
+ | "ai.streamObject.doStream"
49
+ | "ai.embed"
50
+ | "ai.embed.doEmbed"
51
+ | "ai.embedMany"
52
+ | "ai.embedMany.doEmbed"
53
+ | "ai.toolCall";
54
+
55
+ /**
56
+ * Extracted AI attributes from OTel span
57
+ */
58
+ export interface AISpanAttributes {
59
+ // Operation info
60
+ operationId?: string;
61
+ functionId?: string;
62
+
63
+ // Model info
64
+ modelId?: string;
65
+ modelProvider?: string;
66
+
67
+ // Request info
68
+ prompt?: string;
69
+ promptMessages?: string;
70
+ promptTools?: string;
71
+
72
+ // Response info
73
+ responseText?: string;
74
+ responseObject?: string;
75
+ responseToolCalls?: string;
76
+ responseFinishReason?: string;
77
+ responseId?: string;
78
+ responseModel?: string;
79
+
80
+ // Usage
81
+ promptTokens?: number;
82
+ completionTokens?: number;
83
+ totalTokens?: number;
84
+
85
+ // Timing (streaming)
86
+ msToFirstChunk?: number;
87
+ msToFinish?: number;
88
+ avgCompletionTokensPerSecond?: number;
89
+
90
+ // Tool calls
91
+ toolCallName?: string;
92
+ toolCallId?: string;
93
+ toolCallArgs?: string;
94
+ toolCallResult?: string;
95
+
96
+ // GenAI semantic conventions
97
+ genAiSystem?: string;
98
+ genAiRequestModel?: string;
99
+ genAiResponseModel?: string;
100
+ genAiInputTokens?: number;
101
+ genAiOutputTokens?: number;
102
+
103
+ // Custom metadata
104
+ metadata?: Record<string, unknown>;
105
+ }
106
+
107
+ /**
108
+ * Configuration for the NodeLLM SpanProcessor
109
+ */
110
+ export interface NodeLLMSpanProcessorOptions {
111
+ /**
112
+ * Whether to capture prompt/response content.
113
+ * Default: true (respects Vercel AI SDK's recordInputs/recordOutputs settings)
114
+ */
115
+ captureContent?: boolean;
116
+
117
+ /**
118
+ * Filter function to decide which spans to process.
119
+ * Return true to process, false to skip.
120
+ * Default: processes all ai.* spans
121
+ */
122
+ filter?: (span: OTelReadableSpan) => boolean;
123
+
124
+ /**
125
+ * Custom transform function to modify the monitoring event before saving.
126
+ */
127
+ transform?: (
128
+ event: import("@node-llm/monitor").MonitoringEvent,
129
+ span: OTelReadableSpan
130
+ ) => import("@node-llm/monitor").MonitoringEvent;
131
+
132
+ /**
133
+ * Session ID extractor. By default, looks for ai.telemetry.metadata.sessionId
134
+ */
135
+ sessionIdExtractor?: (span: OTelReadableSpan) => string | undefined;
136
+
137
+ /**
138
+ * Error handler for storage failures
139
+ */
140
+ onError?: (error: Error, span: OTelReadableSpan) => void;
141
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Utility functions for extracting and mapping OpenTelemetry attributes
3
+ * to NodeLLM Monitor events.
4
+ */
5
+
6
+ import type {
7
+ OTelReadableSpan,
8
+ AttributeValue,
9
+ OTelAttributes,
10
+ AISpanAttributes,
11
+ AIOperationType
12
+ } from "./types.js";
13
+
14
+ /**
15
+ * Known AI operation span names from Vercel AI SDK and standard GenAI conventions
16
+ */
17
+ const AI_OPERATION_PREFIXES = [
18
+ "ai.generateText",
19
+ "ai.streamText",
20
+ "ai.generateObject",
21
+ "ai.streamObject",
22
+ "ai.embed",
23
+ "ai.embedMany",
24
+ "ai.toolCall",
25
+ "gen_ai." // Standard OTel prefix
26
+ ];
27
+
28
+ /**
29
+ * Check if a span is an AI-related span (Vercel AI SDK or standard GenAI)
30
+ */
31
+ export function isAISpan(span: OTelReadableSpan): boolean {
32
+ const hasAIPrefix = AI_OPERATION_PREFIXES.some((prefix) => span.name.startsWith(prefix));
33
+ const hasAIAttributes =
34
+ span.attributes &&
35
+ (span.attributes["ai.operationId"] ||
36
+ span.attributes["gen_ai.system"] ||
37
+ span.attributes["gen_ai.request.model"]);
38
+
39
+ return !!(hasAIPrefix || hasAIAttributes);
40
+ }
41
+
42
+ /**
43
+ * Check if this is a top-level operation span (not a .doGenerate/.doStream child)
44
+ */
45
+ export function isTopLevelAISpan(span: OTelReadableSpan): boolean {
46
+ const name = span.name;
47
+ const operationId = span.attributes["ai.operationId"] as string | undefined;
48
+
49
+ // Top-level spans don't have .doGenerate, .doStream, or .doEmbed suffix
50
+ const isDoSpan =
51
+ name.includes(".doGenerate") || name.includes(".doStream") || name.includes(".doEmbed");
52
+
53
+ // Tool calls are their own top-level spans
54
+ const isToolCall = operationId === "ai.toolCall" || name === "ai.toolCall";
55
+
56
+ return !isDoSpan || isToolCall;
57
+ }
58
+
59
+ /**
60
+ * Get the operation type from a span
61
+ */
62
+ export function getOperationType(span: OTelReadableSpan): AIOperationType | undefined {
63
+ const operationId = span.attributes["ai.operationId"] as string | undefined;
64
+ if (operationId) {
65
+ return operationId as AIOperationType;
66
+ }
67
+
68
+ // Fallback to span name
69
+ for (const prefix of AI_OPERATION_PREFIXES) {
70
+ if (span.name.startsWith(prefix)) {
71
+ return span.name.split(" ")[0] as AIOperationType;
72
+ }
73
+ }
74
+
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * Extract a string attribute safely
80
+ */
81
+ function getString(attrs: OTelAttributes, key: string): string | undefined {
82
+ const value = attrs[key];
83
+ return typeof value === "string" ? value : undefined;
84
+ }
85
+
86
+ /**
87
+ * Extract a number attribute safely
88
+ */
89
+ function getNumber(attrs: OTelAttributes, key: string): number | undefined {
90
+ const value = attrs[key];
91
+ return typeof value === "number" ? value : undefined;
92
+ }
93
+
94
+ /**
95
+ * Extract all AI-related attributes from a span
96
+ */
97
+ export function extractAIAttributes(span: OTelReadableSpan): AISpanAttributes {
98
+ const attrs = span.attributes;
99
+
100
+ // Extract custom metadata (ai.telemetry.metadata.*)
101
+ const metadata: Record<string, unknown> = {};
102
+ for (const [key, value] of Object.entries(attrs)) {
103
+ if (key.startsWith("ai.telemetry.metadata.")) {
104
+ const metaKey = key.replace("ai.telemetry.metadata.", "");
105
+ metadata[metaKey] = value;
106
+ }
107
+ }
108
+
109
+ return {
110
+ // Operation info
111
+ operationId: getString(attrs, "ai.operationId"),
112
+ functionId: getString(attrs, "ai.telemetry.functionId") || getString(attrs, "resource.name"),
113
+
114
+ // Model info
115
+ modelId: getString(attrs, "ai.model.id"),
116
+ modelProvider: getString(attrs, "ai.model.provider"),
117
+
118
+ // Request info
119
+ prompt: getString(attrs, "ai.prompt"),
120
+ promptMessages: getString(attrs, "ai.prompt.messages"),
121
+ promptTools: getString(attrs, "ai.prompt.tools"),
122
+
123
+ // Response info
124
+ responseText: getString(attrs, "ai.response.text"),
125
+ responseObject: getString(attrs, "ai.response.object"),
126
+ responseToolCalls: getString(attrs, "ai.response.toolCalls"),
127
+ responseFinishReason: getString(attrs, "ai.response.finishReason"),
128
+ responseId: getString(attrs, "ai.response.id"),
129
+ responseModel: getString(attrs, "ai.response.model"),
130
+
131
+ // Usage - AI SDK attributes
132
+ promptTokens: getNumber(attrs, "ai.usage.promptTokens"),
133
+ completionTokens: getNumber(attrs, "ai.usage.completionTokens"),
134
+
135
+ // Timing (streaming)
136
+ msToFirstChunk: getNumber(attrs, "ai.response.msToFirstChunk"),
137
+ msToFinish: getNumber(attrs, "ai.response.msToFinish"),
138
+ avgCompletionTokensPerSecond: getNumber(attrs, "ai.response.avgCompletionTokensPerSecond"),
139
+
140
+ // Tool calls
141
+ toolCallName: getString(attrs, "ai.toolCall.name"),
142
+ toolCallId: getString(attrs, "ai.toolCall.id"),
143
+ toolCallArgs: getString(attrs, "ai.toolCall.args"),
144
+ toolCallResult: getString(attrs, "ai.toolCall.result"),
145
+
146
+ // GenAI semantic conventions (OpenTelemetry standard)
147
+ genAiSystem: getString(attrs, "gen_ai.system"),
148
+ genAiRequestModel: getString(attrs, "gen_ai.request.model"),
149
+ genAiResponseModel: getString(attrs, "gen_ai.response.model"),
150
+ genAiInputTokens: getNumber(attrs, "gen_ai.usage.input_tokens"),
151
+ genAiOutputTokens: getNumber(attrs, "gen_ai.usage.output_tokens"),
152
+
153
+ // Custom metadata
154
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Convert OTel high-resolution time [seconds, nanoseconds] to Date
160
+ */
161
+ export function hrTimeToDate(hrTime: [number, number]): Date {
162
+ const [seconds, nanoseconds] = hrTime;
163
+ return new Date(seconds * 1000 + nanoseconds / 1_000_000);
164
+ }
165
+
166
+ /**
167
+ * Calculate duration in milliseconds from OTel times
168
+ */
169
+ export function calculateDurationMs(
170
+ startTime: [number, number],
171
+ endTime: [number, number]
172
+ ): number {
173
+ const startMs = startTime[0] * 1000 + startTime[1] / 1_000_000;
174
+ const endMs = endTime[0] * 1000 + endTime[1] / 1_000_000;
175
+ return Math.round(endMs - startMs);
176
+ }
177
+
178
+ /**
179
+ * Extract provider from model ID or attributes
180
+ * Handles Vercel AI SDK format (e.g., "openai.responses" -> "openai")
181
+ * Examples:
182
+ * - "openai" from ai.model.provider
183
+ * - "openai" from "gpt-4o-mini"
184
+ * - "anthropic" from "claude-3-5-sonnet"
185
+ */
186
+ export function extractProvider(attrs: AISpanAttributes): string {
187
+ // Direct provider attribute
188
+ if (attrs.modelProvider) {
189
+ return normalizeProviderName(attrs.modelProvider);
190
+ }
191
+
192
+ // GenAI system
193
+ if (attrs.genAiSystem) {
194
+ return normalizeProviderName(attrs.genAiSystem);
195
+ }
196
+
197
+ // Infer from model ID
198
+ const modelId = attrs.modelId || attrs.genAiRequestModel || "";
199
+
200
+ if (modelId.startsWith("gpt-") || modelId.includes("openai")) {
201
+ return "openai";
202
+ }
203
+ if (modelId.startsWith("claude-") || modelId.includes("anthropic")) {
204
+ return "anthropic";
205
+ }
206
+ if (modelId.startsWith("gemini-") || modelId.includes("google")) {
207
+ return "google";
208
+ }
209
+ if (modelId.startsWith("deepseek-")) {
210
+ return "deepseek";
211
+ }
212
+ if (modelId.includes("llama") || modelId.includes("mistral")) {
213
+ return "meta";
214
+ }
215
+
216
+ return "unknown";
217
+ }
218
+
219
+ /**
220
+ * Normalize provider name by stripping operation suffixes
221
+ * Examples:
222
+ * - "openai.responses" -> "openai"
223
+ * - "anthropic.messages" -> "anthropic"
224
+ * - "google.generativeai" -> "google"
225
+ * - "openai" -> "openai" (unchanged)
226
+ */
227
+ export function normalizeProviderName(provider: string): string {
228
+ if (provider.includes(".")) {
229
+ return provider.split(".")[0] || provider;
230
+ }
231
+ return provider;
232
+ }
233
+
234
+ /**
235
+ * Extract model name from attributes
236
+ * Handles various formats including:
237
+ * - "gpt-4o-mini" (standard)
238
+ * - "openai.responses/gpt-4o-mini" (Vercel AI SDK format)
239
+ */
240
+ export function extractModel(attrs: AISpanAttributes): string {
241
+ const rawModel =
242
+ attrs.responseModel ||
243
+ attrs.modelId ||
244
+ attrs.genAiResponseModel ||
245
+ attrs.genAiRequestModel ||
246
+ "unknown";
247
+
248
+ return normalizeModelName(rawModel);
249
+ }
250
+
251
+ /**
252
+ * Normalize model name by stripping provider prefixes
253
+ * Examples:
254
+ * - "openai.responses/gpt-4o-mini" -> "gpt-4o-mini"
255
+ * - "anthropic.messages/claude-3-5-sonnet" -> "claude-3-5-sonnet"
256
+ * - "gpt-4o-mini" -> "gpt-4o-mini" (unchanged)
257
+ */
258
+ export function normalizeModelName(model: string): string {
259
+ if (model.includes("/")) {
260
+ const parts = model.split("/");
261
+ return parts[parts.length - 1] || model;
262
+ }
263
+ return model;
264
+ }
265
+
266
+ /**
267
+ * Map span status code to event type
268
+ */
269
+ export function mapStatusToEventType(statusCode: number, operationType?: AIOperationType): string {
270
+ // OTel status codes: 0=UNSET, 1=OK, 2=ERROR
271
+ const isError = statusCode === 2;
272
+
273
+ if (operationType?.includes("toolCall")) {
274
+ return isError ? "tool.error" : "tool.end";
275
+ }
276
+
277
+ return isError ? "request.error" : "request.end";
278
+ }
279
+
280
+ /**
281
+ * Generate a deterministic request ID from trace/span IDs
282
+ */
283
+ export function generateRequestId(span: OTelReadableSpan): string {
284
+ const ctx = span.spanContext();
285
+ // Use trace ID + span ID for uniqueness
286
+ return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
287
+ }
288
+
289
+ /**
290
+ * Extract session ID from span attributes
291
+ */
292
+ export function extractSessionId(span: OTelReadableSpan): string | undefined {
293
+ const attrs = span.attributes;
294
+
295
+ // Check common session ID locations
296
+ return (
297
+ getString(attrs, "ai.telemetry.metadata.sessionId") ||
298
+ getString(attrs, "ai.telemetry.metadata.session_id") ||
299
+ getString(attrs, "session.id") ||
300
+ getString(attrs, "sessionId")
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Parse JSON string attributes safely
306
+ */
307
+ export function parseJsonAttribute<T>(value: string | undefined): T | undefined {
308
+ if (!value) return undefined;
309
+ try {
310
+ return JSON.parse(value) as T;
311
+ } catch {
312
+ return undefined;
313
+ }
314
+ }