@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.
- package/README.md +68 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +325 -0
- package/package.json +26 -0
- package/src/NodeLLMSpanProcessor.ts +257 -0
- package/src/index.ts +1 -0
- package/src/types.ts +141 -0
- package/src/utils.ts +314 -0
- package/test/NodeLLMSpanProcessor.test.ts +165 -0
- package/test/fixtures/vercel-ai-spans.ts +379 -0
- package/test/integration.test.ts +432 -0
- package/test/utils.test.ts +278 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|