@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,165 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { NodeLLMSpanProcessor } from "../src/NodeLLMSpanProcessor.js";
3
+ import { MonitoringStore, MonitoringEvent } from "@node-llm/monitor";
4
+
5
+ describe("NodeLLMSpanProcessor", () => {
6
+ let mockStore: MonitoringStore;
7
+ let processor: NodeLLMSpanProcessor;
8
+
9
+ beforeEach(() => {
10
+ mockStore = {
11
+ saveEvent: vi.fn().mockResolvedValue(undefined)
12
+ } as any;
13
+ processor = new NodeLLMSpanProcessor(mockStore);
14
+ });
15
+
16
+ const createMockSpan = (name: string, overrides = {}) => {
17
+ return {
18
+ name,
19
+ spanContext: () => ({ traceId: "trace-1", spanId: "span-2" }),
20
+ parentSpanId: "parent-0",
21
+ startTime: [1000, 0],
22
+ endTime: [1001, 0],
23
+ status: { code: 1 },
24
+ attributes: {
25
+ "ai.model.id": "gpt-4o",
26
+ "ai.model.provider": "openai",
27
+ "ai.usage.promptTokens": 100,
28
+ "ai.usage.completionTokens": 50,
29
+ ...overrides
30
+ },
31
+ ...overrides
32
+ } as any;
33
+ };
34
+
35
+ it("should process top-level AI spans", async () => {
36
+ const span = createMockSpan("ai.generateText");
37
+
38
+ processor.onEnd(span);
39
+ await processor.forceFlush();
40
+
41
+ expect(mockStore.saveEvent).toHaveBeenCalled();
42
+ const event = (mockStore.saveEvent as any).mock.calls[0][0] as MonitoringEvent;
43
+
44
+ expect(event.provider).toBe("openai");
45
+ expect(event.model).toBe("gpt-4o");
46
+ expect(event.eventType).toBe("request.end");
47
+ expect(event.payload.usage.promptTokens).toBe(100);
48
+ expect(event.payload.usage.completionTokens).toBe(50);
49
+ });
50
+
51
+ it("should process standard (Non-Vercel) GenAI spans", async () => {
52
+ const span = createMockSpan("gen_ai.chat", {
53
+ attributes: {
54
+ "ai.model.id": undefined, // No Vercel attributes
55
+ "ai.model.provider": undefined,
56
+ "gen_ai.system": "anthropic",
57
+ "gen_ai.request.model": "claude-3-5-sonnet",
58
+ "gen_ai.usage.input_tokens": 150,
59
+ "gen_ai.usage.output_tokens": 75
60
+ }
61
+ });
62
+
63
+ processor.onEnd(span);
64
+ await processor.forceFlush();
65
+
66
+ expect(mockStore.saveEvent).toHaveBeenCalled();
67
+ const event = (mockStore.saveEvent as any).mock.calls[0][0];
68
+
69
+ expect(event.provider).toBe("anthropic");
70
+ expect(event.model).toBe("claude-3-5-sonnet");
71
+ expect(event.payload.usage.promptTokens).toBe(150);
72
+ expect(event.payload.usage.totalTokens).toBe(225);
73
+ });
74
+
75
+ it("should ignore non-AI spans", async () => {
76
+ const span = createMockSpan("http.get");
77
+
78
+ processor.onEnd(span);
79
+ await processor.forceFlush();
80
+
81
+ expect(mockStore.saveEvent).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("should ignore internal (.doGenerate) AI spans", async () => {
85
+ const span = createMockSpan("ai.generateText.doGenerate");
86
+
87
+ processor.onEnd(span);
88
+ await processor.forceFlush();
89
+
90
+ expect(mockStore.saveEvent).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("should apply custom filter if provided", async () => {
94
+ const filter = (span: any) => span.attributes.isAllowed === true;
95
+ processor = new NodeLLMSpanProcessor(mockStore, { filter });
96
+
97
+ const span1 = createMockSpan("ai.generateText", { attributes: { isAllowed: false } });
98
+ const span2 = createMockSpan("ai.generateText", { attributes: { isAllowed: true } });
99
+
100
+ processor.onEnd(span1);
101
+ processor.onEnd(span2);
102
+ await processor.forceFlush();
103
+
104
+ expect(mockStore.saveEvent).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("should apply custom transform if provided", async () => {
108
+ const transform = (event: MonitoringEvent) => {
109
+ event.payload.customField = "modified";
110
+ return event;
111
+ };
112
+ processor = new NodeLLMSpanProcessor(mockStore, { transform });
113
+
114
+ const span = createMockSpan("ai.generateText");
115
+ processor.onEnd(span);
116
+ await processor.forceFlush();
117
+
118
+ const event = (mockStore.saveEvent as any).mock.calls[0][0];
119
+ expect(event.payload.customField).toBe("modified");
120
+ });
121
+
122
+ it("should capture content only when enabled", async () => {
123
+ // Enabled by default
124
+ const span = createMockSpan("ai.generateText", {
125
+ attributes: { "ai.prompt": "secret message" }
126
+ });
127
+
128
+ processor.onEnd(span);
129
+ await processor.forceFlush();
130
+ expect((mockStore.saveEvent as any).mock.calls[0][0].payload.prompt).toBe("secret message");
131
+
132
+ // Disabled
133
+ vi.clearAllMocks();
134
+ processor = new NodeLLMSpanProcessor(mockStore, { captureContent: false });
135
+ processor.onEnd(span);
136
+ await processor.forceFlush();
137
+ expect((mockStore.saveEvent as any).mock.calls[0][0].payload.prompt).toBeUndefined();
138
+ });
139
+
140
+ it("should handle errors using onError callback", async () => {
141
+ const error = new Error("Database down");
142
+ (mockStore.saveEvent as any).mockRejectedValue(error);
143
+
144
+ const onError = vi.fn();
145
+ processor = new NodeLLMSpanProcessor(mockStore, { onError });
146
+
147
+ const span = createMockSpan("ai.generateText");
148
+ processor.onEnd(span);
149
+
150
+ // We need to wait for the async processSpan to fail
151
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error, span));
152
+ });
153
+
154
+ it("should respect custom sessionIdExtractor", async () => {
155
+ const sessionIdExtractor = (span: any) => "custom-session-id";
156
+ processor = new NodeLLMSpanProcessor(mockStore, { sessionIdExtractor });
157
+
158
+ const span = createMockSpan("ai.generateText");
159
+ processor.onEnd(span);
160
+ await processor.forceFlush();
161
+
162
+ const event = (mockStore.saveEvent as any).mock.calls[0][0];
163
+ expect(event.sessionId).toBe("custom-session-id");
164
+ });
165
+ });
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Recorded span fixtures from Vercel AI SDK
3
+ *
4
+ * These represent real-world span data captured from various
5
+ * Vercel AI SDK operations for integration testing.
6
+ */
7
+
8
+ import type { OTelReadableSpan } from "../../src/types.js";
9
+
10
+ /**
11
+ * Helper to create a mock span with realistic structure
12
+ */
13
+ function createSpanFixture(
14
+ name: string,
15
+ attributes: Record<string, unknown>,
16
+ overrides: Partial<{
17
+ traceId: string;
18
+ spanId: string;
19
+ parentSpanId: string;
20
+ startTime: [number, number];
21
+ endTime: [number, number];
22
+ statusCode: number;
23
+ statusMessage: string;
24
+ }> = {}
25
+ ): OTelReadableSpan {
26
+ const {
27
+ traceId = "a1b2c3d4e5f6789012345678901234ab",
28
+ spanId = "12345678abcdef01",
29
+ parentSpanId,
30
+ startTime = [1706918400, 0], // 2024-02-03 00:00:00
31
+ endTime = [1706918401, 500000000], // 1.5s later
32
+ statusCode = 1,
33
+ statusMessage
34
+ } = overrides;
35
+
36
+ return {
37
+ name,
38
+ spanContext: () => ({ traceId, spanId }),
39
+ parentSpanId,
40
+ startTime,
41
+ endTime,
42
+ status: { code: statusCode, message: statusMessage },
43
+ attributes
44
+ } as OTelReadableSpan;
45
+ }
46
+
47
+ // ============================================================================
48
+ // OpenAI Spans
49
+ // ============================================================================
50
+
51
+ /**
52
+ * ai.generateText with OpenAI gpt-4o-mini
53
+ * Recorded from: generateText({ model: openai("gpt-4o-mini"), prompt: "Hello" })
54
+ */
55
+ export const openaiGenerateText = createSpanFixture(
56
+ "ai.generateText",
57
+ {
58
+ "ai.operationId": "ai.generateText",
59
+ "ai.model.id": "gpt-4o-mini",
60
+ "ai.model.provider": "openai.responses",
61
+ "ai.usage.promptTokens": 12,
62
+ "ai.usage.completionTokens": 28,
63
+ "ai.prompt": "Hello, how are you?",
64
+ "ai.response.text": "I'm doing well, thank you for asking! How can I help you today?",
65
+ "ai.response.finishReason": "stop",
66
+ "ai.telemetry.functionId": "chat-completion"
67
+ },
68
+ {
69
+ traceId: "openai-trace-001",
70
+ spanId: "openai-span-001"
71
+ }
72
+ );
73
+
74
+ /**
75
+ * ai.streamText with OpenAI gpt-4o
76
+ * Includes streaming metrics
77
+ */
78
+ export const openaiStreamText = createSpanFixture(
79
+ "ai.streamText",
80
+ {
81
+ "ai.operationId": "ai.streamText",
82
+ "ai.model.id": "gpt-4o",
83
+ "ai.model.provider": "openai.chat",
84
+ "ai.usage.promptTokens": 156,
85
+ "ai.usage.completionTokens": 423,
86
+ "ai.prompt.messages": JSON.stringify([
87
+ { role: "system", content: "You are a helpful assistant." },
88
+ { role: "user", content: "Explain quantum computing in simple terms." }
89
+ ]),
90
+ "ai.response.text": "Quantum computing is a type of computation...",
91
+ "ai.response.finishReason": "stop",
92
+ "ai.response.msToFirstChunk": 145,
93
+ "ai.response.msToFinish": 2340,
94
+ "ai.response.avgCompletionTokensPerSecond": 180.5,
95
+ "ai.telemetry.metadata.userId": "user-123",
96
+ "ai.telemetry.metadata.environment": "production"
97
+ },
98
+ {
99
+ traceId: "openai-trace-002",
100
+ spanId: "openai-span-002",
101
+ startTime: [1706918500, 0],
102
+ endTime: [1706918502, 340000000]
103
+ }
104
+ );
105
+
106
+ /**
107
+ * ai.generateObject with OpenAI - structured output
108
+ */
109
+ export const openaiGenerateObject = createSpanFixture(
110
+ "ai.generateObject",
111
+ {
112
+ "ai.operationId": "ai.generateObject",
113
+ "ai.model.id": "gpt-4o-mini",
114
+ "ai.model.provider": "openai.responses",
115
+ "ai.usage.promptTokens": 89,
116
+ "ai.usage.completionTokens": 156,
117
+ "ai.prompt": "Extract the person's name and age from: John Smith is 32 years old.",
118
+ "ai.response.object": JSON.stringify({ name: "John Smith", age: 32 }),
119
+ "ai.response.finishReason": "stop"
120
+ },
121
+ {
122
+ traceId: "openai-trace-003",
123
+ spanId: "openai-span-003"
124
+ }
125
+ );
126
+
127
+ // ============================================================================
128
+ // Anthropic Spans
129
+ // ============================================================================
130
+
131
+ /**
132
+ * ai.generateText with Anthropic Claude
133
+ */
134
+ export const anthropicGenerateText = createSpanFixture(
135
+ "ai.generateText",
136
+ {
137
+ "ai.operationId": "ai.generateText",
138
+ "ai.model.id": "claude-3-5-sonnet-20241022",
139
+ "ai.model.provider": "anthropic.messages",
140
+ "ai.usage.promptTokens": 45,
141
+ "ai.usage.completionTokens": 312,
142
+ "ai.prompt": "Write a haiku about programming.",
143
+ "ai.response.text": "Code flows like water\nBugs emerge from the shadows\nDebug, test, repeat",
144
+ "ai.response.finishReason": "end_turn"
145
+ },
146
+ {
147
+ traceId: "anthropic-trace-001",
148
+ spanId: "anthropic-span-001"
149
+ }
150
+ );
151
+
152
+ /**
153
+ * ai.streamText with Anthropic Claude (using genAi attributes)
154
+ */
155
+ export const anthropicGenAiFormat = createSpanFixture(
156
+ "gen_ai.chat",
157
+ {
158
+ "gen_ai.system": "anthropic",
159
+ "gen_ai.request.model": "claude-3-opus",
160
+ "gen_ai.usage.input_tokens": 234,
161
+ "gen_ai.usage.output_tokens": 567
162
+ },
163
+ {
164
+ traceId: "anthropic-trace-002",
165
+ spanId: "anthropic-span-002"
166
+ }
167
+ );
168
+
169
+ // ============================================================================
170
+ // Google Gemini Spans
171
+ // ============================================================================
172
+
173
+ /**
174
+ * ai.generateText with Google Gemini
175
+ */
176
+ export const geminiGenerateText = createSpanFixture(
177
+ "ai.generateText",
178
+ {
179
+ "ai.operationId": "ai.generateText",
180
+ "ai.model.id": "gemini-1.5-pro",
181
+ "ai.model.provider": "google.generativeai",
182
+ "ai.usage.promptTokens": 78,
183
+ "ai.usage.completionTokens": 245,
184
+ "ai.prompt": "What is the capital of France?",
185
+ "ai.response.text": "The capital of France is Paris...",
186
+ "ai.response.finishReason": "STOP"
187
+ },
188
+ {
189
+ traceId: "google-trace-001",
190
+ spanId: "google-span-001"
191
+ }
192
+ );
193
+
194
+ // ============================================================================
195
+ // Tool Call Spans
196
+ // ============================================================================
197
+
198
+ /**
199
+ * ai.toolCall span - weather tool
200
+ */
201
+ export const toolCallWeather = createSpanFixture(
202
+ "ai.toolCall",
203
+ {
204
+ "ai.operationId": "ai.toolCall",
205
+ "ai.toolCall.name": "getWeather",
206
+ "ai.toolCall.id": "call_abc123",
207
+ "ai.toolCall.args": JSON.stringify({ city: "London", units: "celsius" }),
208
+ "ai.toolCall.result": JSON.stringify({ temperature: 18, condition: "cloudy" })
209
+ },
210
+ {
211
+ traceId: "tool-trace-001",
212
+ spanId: "tool-span-001",
213
+ parentSpanId: "parent-span-001"
214
+ }
215
+ );
216
+
217
+ /**
218
+ * ai.toolCall span - database query
219
+ */
220
+ export const toolCallDatabase = createSpanFixture(
221
+ "ai.toolCall",
222
+ {
223
+ "ai.operationId": "ai.toolCall",
224
+ "ai.toolCall.name": "queryDatabase",
225
+ "ai.toolCall.id": "call_def456",
226
+ "ai.toolCall.args": JSON.stringify({ query: "SELECT * FROM users WHERE active = true" }),
227
+ "ai.toolCall.result": JSON.stringify({ rows: 42, duration: "12ms" })
228
+ },
229
+ {
230
+ traceId: "tool-trace-002",
231
+ spanId: "tool-span-002",
232
+ parentSpanId: "parent-span-002"
233
+ }
234
+ );
235
+
236
+ // ============================================================================
237
+ // Error Spans
238
+ // ============================================================================
239
+
240
+ /**
241
+ * ai.generateText with rate limit error
242
+ */
243
+ export const errorRateLimit = createSpanFixture(
244
+ "ai.generateText",
245
+ {
246
+ "ai.operationId": "ai.generateText",
247
+ "ai.model.id": "gpt-4o",
248
+ "ai.model.provider": "openai.responses",
249
+ "ai.prompt": "This request will fail"
250
+ },
251
+ {
252
+ traceId: "error-trace-001",
253
+ spanId: "error-span-001",
254
+ statusCode: 2,
255
+ statusMessage: "Rate limit exceeded. Please retry after 60 seconds."
256
+ }
257
+ );
258
+
259
+ /**
260
+ * ai.generateText with context length error
261
+ */
262
+ export const errorContextLength = createSpanFixture(
263
+ "ai.generateText",
264
+ {
265
+ "ai.operationId": "ai.generateText",
266
+ "ai.model.id": "gpt-4o-mini",
267
+ "ai.model.provider": "openai.chat",
268
+ "ai.prompt": "Very long prompt that exceeds context..."
269
+ },
270
+ {
271
+ traceId: "error-trace-002",
272
+ spanId: "error-span-002",
273
+ statusCode: 2,
274
+ statusMessage: "This model's maximum context length is 128000 tokens."
275
+ }
276
+ );
277
+
278
+ // ============================================================================
279
+ // Internal Spans (should be filtered out)
280
+ // ============================================================================
281
+
282
+ /**
283
+ * Internal .doGenerate span (should NOT be processed)
284
+ */
285
+ export const internalDoGenerate = createSpanFixture(
286
+ "ai.generateText.doGenerate",
287
+ {
288
+ "ai.model.id": "gpt-4o-mini",
289
+ "ai.model.provider": "openai.responses",
290
+ "ai.usage.promptTokens": 12,
291
+ "ai.usage.completionTokens": 28
292
+ },
293
+ {
294
+ traceId: "internal-trace-001",
295
+ spanId: "internal-span-001",
296
+ parentSpanId: "parent-span-001"
297
+ }
298
+ );
299
+
300
+ /**
301
+ * HTTP span (should NOT be processed)
302
+ */
303
+ export const httpRequest = createSpanFixture(
304
+ "HTTP POST",
305
+ {
306
+ "http.method": "POST",
307
+ "http.url": "https://api.openai.com/v1/chat/completions",
308
+ "http.status_code": 200
309
+ },
310
+ {
311
+ traceId: "http-trace-001",
312
+ spanId: "http-span-001"
313
+ }
314
+ );
315
+
316
+ // ============================================================================
317
+ // Embedding Spans
318
+ // ============================================================================
319
+
320
+ /**
321
+ * ai.embed span
322
+ */
323
+ export const openaiEmbed = createSpanFixture(
324
+ "ai.embed",
325
+ {
326
+ "ai.operationId": "ai.embed",
327
+ "ai.model.id": "text-embedding-3-small",
328
+ "ai.model.provider": "openai.embedding",
329
+ "ai.usage.promptTokens": 8
330
+ },
331
+ {
332
+ traceId: "embed-trace-001",
333
+ spanId: "embed-span-001"
334
+ }
335
+ );
336
+
337
+ /**
338
+ * ai.embedMany span
339
+ */
340
+ export const openaiEmbedMany = createSpanFixture(
341
+ "ai.embedMany",
342
+ {
343
+ "ai.operationId": "ai.embedMany",
344
+ "ai.model.id": "text-embedding-3-large",
345
+ "ai.model.provider": "openai.embedding",
346
+ "ai.usage.promptTokens": 156
347
+ },
348
+ {
349
+ traceId: "embed-trace-002",
350
+ spanId: "embed-span-002"
351
+ }
352
+ );
353
+
354
+ // ============================================================================
355
+ // Export all fixtures for easy iteration
356
+ // ============================================================================
357
+
358
+ /**
359
+ * Valid spans that should be processed (excludes tool calls which need special handling)
360
+ */
361
+ export const allValidSpans = [
362
+ openaiGenerateText,
363
+ openaiStreamText,
364
+ openaiGenerateObject,
365
+ anthropicGenerateText,
366
+ anthropicGenAiFormat,
367
+ geminiGenerateText,
368
+ openaiEmbed,
369
+ openaiEmbedMany
370
+ ];
371
+
372
+ /**
373
+ * Tool call spans - these are separate as they may need special handling
374
+ */
375
+ export const toolCallSpans = [toolCallWeather, toolCallDatabase];
376
+
377
+ export const errorSpans = [errorRateLimit, errorContextLength];
378
+
379
+ export const filteredOutSpans = [internalDoGenerate, httpRequest];