@node-llm/monitor-otel 0.1.1 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.0] - 2026-11-10
6
+
7
+ ### Features
8
+
9
+ - **Tags from spans.** `llm.tag.<key>` span attributes, and the Vercel AI SDK's telemetry metadata `tag.<key>`, become the call's tags, for the dashboard's tag filters in `@node-llm/monitor` 0.5.0.
10
+
11
+ ## [0.1.1] - 2026-07-04
12
+
13
+ ### Features
14
+
15
+ - **Extended Token Accounting**: Extract and store cache, cache-creation, reasoning, and image token counts in `NodeLLMSpanProcessor`, matching the same accounting added to `@node-llm/monitor` 0.4.2.
package/README.md CHANGED
@@ -59,10 +59,17 @@ The spans emitted by `ai` will be automatically intercepted, processed, and save
59
59
  new NodeLLMSpanProcessor(store, {
60
60
  captureContent: true, // Set to false to hide prompts/responses from logs
61
61
  filter: (span) => span.name.includes("my-app"), // Custom span filtering
62
+ includeNodeLLMSpans: false, // See "NodeLLM's own spans" below
62
63
  onError: (err, span) => console.error("Failed to process AI span", err)
63
64
  });
64
65
  ```
65
66
 
67
+ ### NodeLLM's own spans
68
+
69
+ `@node-llm/core` can also create OpenTelemetry spans, with its `OpenTelemetry(otel)` middleware. The processor skips those spans by default (they carry a `node_llm.request_id` attribute), because the `Monitor` middleware already records the same calls and they would be counted twice.
70
+
71
+ If NodeLLM reaches Monitor only through OpenTelemetry, without the `Monitor` middleware, set `includeNodeLLMSpans: true`.
72
+
66
73
  ## License
67
74
 
68
75
  MIT
package/dist/index.d.ts CHANGED
@@ -36,6 +36,14 @@ interface NodeLLMSpanProcessorOptions {
36
36
  * Session ID extractor. By default, looks for ai.telemetry.metadata.sessionId
37
37
  */
38
38
  sessionIdExtractor?: (span: OTelReadableSpan) => string | undefined;
39
+ /**
40
+ * Whether to record spans that NodeLLM's own OpenTelemetry middleware
41
+ * created (they carry a `node_llm.request_id` attribute).
42
+ * Default: false. The Monitor middleware already records NodeLLM calls, so
43
+ * recording these spans too would count each call twice. Set to true when
44
+ * NodeLLM reaches Monitor only through OpenTelemetry.
45
+ */
46
+ includeNodeLLMSpans?: boolean;
39
47
  /**
40
48
  * Error handler for storage failures
41
49
  */
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ function isAISpan(span) {
18
18
  const hasAIAttributes = span.attributes && (span.attributes["ai.operationId"] || span.attributes["gen_ai.system"] || span.attributes["gen_ai.request.model"]);
19
19
  return !!(hasAIPrefix || hasAIAttributes);
20
20
  }
21
+ function isNodeLLMSpan(span) {
22
+ return span.attributes?.["node_llm.request_id"] !== void 0;
23
+ }
21
24
  function isTopLevelAISpan(span) {
22
25
  const name = span.name;
23
26
  const operationId = span.attributes["ai.operationId"];
@@ -161,6 +164,15 @@ function generateRequestId(span) {
161
164
  const ctx = span.spanContext();
162
165
  return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
163
166
  }
167
+ function extractTags(span) {
168
+ const tags = {};
169
+ for (const [name, value] of Object.entries(span.attributes ?? {})) {
170
+ const key = name.startsWith("llm.tag.") ? name.slice(8) : name.startsWith("ai.telemetry.metadata.tag.") ? name.slice(26) : void 0;
171
+ if (!key || value === void 0 || value === null || value === "") continue;
172
+ tags[key.slice(0, 64)] = String(value).slice(0, 200);
173
+ }
174
+ return Object.keys(tags).length ? tags : void 0;
175
+ }
164
176
  function extractSessionId(span) {
165
177
  const attrs = span.attributes;
166
178
  return getString(attrs, "ai.telemetry.metadata.sessionId") || getString(attrs, "ai.telemetry.metadata.session_id") || getString(attrs, "session.id") || getString(attrs, "sessionId");
@@ -179,7 +191,7 @@ import { estimateCost } from "@node-llm/monitor";
179
191
  var NodeLLMSpanProcessor = class {
180
192
  store;
181
193
  options;
182
- pendingWrites = [];
194
+ pendingWrites = /* @__PURE__ */ new Set();
183
195
  constructor(store, options = {}) {
184
196
  this.store = store;
185
197
  this.options = {
@@ -200,6 +212,9 @@ var NodeLLMSpanProcessor = class {
200
212
  if (!isAISpan(span)) {
201
213
  return;
202
214
  }
215
+ if (!this.options.includeNodeLLMSpans && isNodeLLMSpan(span)) {
216
+ return;
217
+ }
203
218
  if (this.options.filter && !this.options.filter(span)) {
204
219
  return;
205
220
  }
@@ -213,7 +228,8 @@ var NodeLLMSpanProcessor = class {
213
228
  console.error("[NodeLLMSpanProcessor] Error processing span:", error);
214
229
  }
215
230
  });
216
- this.pendingWrites.push(writePromise);
231
+ this.pendingWrites.add(writePromise);
232
+ void writePromise.finally(() => this.pendingWrites.delete(writePromise));
217
233
  }
218
234
  /**
219
235
  * Process a single span and save as MonitoringEvent
@@ -296,6 +312,7 @@ var NodeLLMSpanProcessor = class {
296
312
  }
297
313
  };
298
314
  }
315
+ const tags = extractTags(span);
299
316
  if (span.status.code === 2) {
300
317
  payload.error = span.status.message || "Unknown error";
301
318
  }
@@ -310,7 +327,8 @@ var NodeLLMSpanProcessor = class {
310
327
  payload,
311
328
  createdAt: /* @__PURE__ */ new Date(),
312
329
  provider,
313
- model
330
+ model,
331
+ ...tags ? { tags } : {}
314
332
  };
315
333
  }
316
334
  /**
@@ -318,7 +336,7 @@ var NodeLLMSpanProcessor = class {
318
336
  */
319
337
  async forceFlush() {
320
338
  await Promise.all(this.pendingWrites);
321
- this.pendingWrites = [];
339
+ this.pendingWrites.clear();
322
340
  }
323
341
  /**
324
342
  * Shutdown the processor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node-llm/monitor-otel",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenTelemetry integration for NodeLLM Monitor",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "dependencies": {
9
9
  "@opentelemetry/api": "^1.7.0",
10
10
  "@opentelemetry/sdk-trace-base": "^1.18.1",
11
- "@node-llm/monitor": "0.4.2"
11
+ "@node-llm/monitor": "0.5.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "@types/node": "^25.2.0",
@@ -35,6 +35,7 @@ import type {
35
35
  } from "./types.js";
36
36
  import {
37
37
  isAISpan,
38
+ isNodeLLMSpan,
38
39
  isTopLevelAISpan,
39
40
  getOperationType,
40
41
  extractAIAttributes,
@@ -45,7 +46,8 @@ import {
45
46
  mapStatusToEventType,
46
47
  generateRequestId,
47
48
  extractSessionId,
48
- parseJsonAttribute
49
+ parseJsonAttribute,
50
+ extractTags
49
51
  } from "./utils.js";
50
52
  import { estimateCost } from "@node-llm/monitor";
51
53
 
@@ -62,7 +64,7 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
62
64
  private readonly store: MonitoringStore;
63
65
  private readonly options: Required<Pick<NodeLLMSpanProcessorOptions, "captureContent">> &
64
66
  NodeLLMSpanProcessorOptions;
65
- private pendingWrites: Promise<void>[] = [];
67
+ private pendingWrites = new Set<Promise<void>>();
66
68
 
67
69
  constructor(store: MonitoringStore, options: NodeLLMSpanProcessorOptions = {}) {
68
70
  this.store = store;
@@ -89,6 +91,11 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
89
91
  return;
90
92
  }
91
93
 
94
+ // Skip NodeLLM's own spans: the Monitor middleware already records those calls
95
+ if (!this.options.includeNodeLLMSpans && isNodeLLMSpan(span)) {
96
+ return;
97
+ }
98
+
92
99
  // Apply custom filter if provided
93
100
  if (this.options.filter && !this.options.filter(span)) {
94
101
  return;
@@ -109,7 +116,10 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
109
116
  }
110
117
  });
111
118
 
112
- this.pendingWrites.push(writePromise);
119
+ // Track until settled so forceFlush can await in-flight writes without
120
+ // the set growing unboundedly in long-running processes.
121
+ this.pendingWrites.add(writePromise);
122
+ void writePromise.finally(() => this.pendingWrites.delete(writePromise));
113
123
  }
114
124
 
115
125
  /**
@@ -216,6 +226,8 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
216
226
  };
217
227
  }
218
228
 
229
+ const tags = extractTags(span);
230
+
219
231
  // Handle errors
220
232
  if (span.status.code === 2) {
221
233
  payload.error = span.status.message || "Unknown error";
@@ -232,7 +244,8 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
232
244
  payload,
233
245
  createdAt: new Date(),
234
246
  provider,
235
- model
247
+ model,
248
+ ...(tags ? { tags } : {})
236
249
  };
237
250
  }
238
251
 
@@ -241,7 +254,7 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
241
254
  */
242
255
  async forceFlush(): Promise<void> {
243
256
  await Promise.all(this.pendingWrites);
244
- this.pendingWrites = [];
257
+ this.pendingWrites.clear();
245
258
  }
246
259
 
247
260
  /**
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export * from './NodeLLMSpanProcessor.js';
1
+ export * from "./NodeLLMSpanProcessor.js";
package/src/types.ts CHANGED
@@ -57,57 +57,57 @@ export type AIOperationType =
57
57
  */
58
58
  export interface AISpanAttributes {
59
59
  // Operation info
60
- operationId?: string;
61
- functionId?: string;
60
+ operationId?: string | undefined;
61
+ functionId?: string | undefined;
62
62
 
63
63
  // Model info
64
- modelId?: string;
65
- modelProvider?: string;
64
+ modelId?: string | undefined;
65
+ modelProvider?: string | undefined;
66
66
 
67
67
  // Request info
68
- prompt?: string;
69
- promptMessages?: string;
70
- promptTools?: string;
68
+ prompt?: string | undefined;
69
+ promptMessages?: string | undefined;
70
+ promptTools?: string | undefined;
71
71
 
72
72
  // Response info
73
- responseText?: string;
74
- responseObject?: string;
75
- responseToolCalls?: string;
76
- responseFinishReason?: string;
77
- responseId?: string;
78
- responseModel?: string;
73
+ responseText?: string | undefined;
74
+ responseObject?: string | undefined;
75
+ responseToolCalls?: string | undefined;
76
+ responseFinishReason?: string | undefined;
77
+ responseId?: string | undefined;
78
+ responseModel?: string | undefined;
79
79
 
80
80
  // Usage
81
- promptTokens?: number;
82
- completionTokens?: number;
83
- totalTokens?: number;
81
+ promptTokens?: number | undefined;
82
+ completionTokens?: number | undefined;
83
+ totalTokens?: number | undefined;
84
84
  // Cache/reasoning usage (forward-compatible AI SDK attributes - not yet
85
- cachedInputTokens?: number;
86
- reasoningTokens?: number;
85
+ cachedInputTokens?: number | undefined;
86
+ reasoningTokens?: number | undefined;
87
87
 
88
88
  // Timing (streaming)
89
- msToFirstChunk?: number;
90
- msToFinish?: number;
91
- avgCompletionTokensPerSecond?: number;
89
+ msToFirstChunk?: number | undefined;
90
+ msToFinish?: number | undefined;
91
+ avgCompletionTokensPerSecond?: number | undefined;
92
92
 
93
93
  // Tool calls
94
- toolCallName?: string;
95
- toolCallId?: string;
96
- toolCallArgs?: string;
97
- toolCallResult?: string;
94
+ toolCallName?: string | undefined;
95
+ toolCallId?: string | undefined;
96
+ toolCallArgs?: string | undefined;
97
+ toolCallResult?: string | undefined;
98
98
 
99
99
  // GenAI semantic conventions
100
- genAiSystem?: string;
101
- genAiRequestModel?: string;
102
- genAiResponseModel?: string;
103
- genAiInputTokens?: number;
104
- genAiOutputTokens?: number;
105
- genAiCacheReadInputTokens?: number;
106
- genAiCacheCreationInputTokens?: number;
107
- genAiReasoningOutputTokens?: number;
100
+ genAiSystem?: string | undefined;
101
+ genAiRequestModel?: string | undefined;
102
+ genAiResponseModel?: string | undefined;
103
+ genAiInputTokens?: number | undefined;
104
+ genAiOutputTokens?: number | undefined;
105
+ genAiCacheReadInputTokens?: number | undefined;
106
+ genAiCacheCreationInputTokens?: number | undefined;
107
+ genAiReasoningOutputTokens?: number | undefined;
108
108
 
109
109
  // Custom metadata
110
- metadata?: Record<string, unknown>;
110
+ metadata?: Record<string, unknown> | undefined;
111
111
  }
112
112
 
113
113
  /**
@@ -140,6 +140,15 @@ export interface NodeLLMSpanProcessorOptions {
140
140
  */
141
141
  sessionIdExtractor?: (span: OTelReadableSpan) => string | undefined;
142
142
 
143
+ /**
144
+ * Whether to record spans that NodeLLM's own OpenTelemetry middleware
145
+ * created (they carry a `node_llm.request_id` attribute).
146
+ * Default: false. The Monitor middleware already records NodeLLM calls, so
147
+ * recording these spans too would count each call twice. Set to true when
148
+ * NodeLLM reaches Monitor only through OpenTelemetry.
149
+ */
150
+ includeNodeLLMSpans?: boolean;
151
+
143
152
  /**
144
153
  * Error handler for storage failures
145
154
  */
package/src/utils.ts CHANGED
@@ -39,6 +39,15 @@ export function isAISpan(span: OTelReadableSpan): boolean {
39
39
  return !!(hasAIPrefix || hasAIAttributes);
40
40
  }
41
41
 
42
+ /**
43
+ * Check if a span was created by NodeLLM's OpenTelemetry middleware
44
+ * (`OpenTelemetry(otel)` from @node-llm/core), which tags each span with
45
+ * `node_llm.request_id`.
46
+ */
47
+ export function isNodeLLMSpan(span: OTelReadableSpan): boolean {
48
+ return span.attributes?.["node_llm.request_id"] !== undefined;
49
+ }
50
+
42
51
  /**
43
52
  * Check if this is a top-level operation span (not a .doGenerate/.doStream child)
44
53
  */
@@ -291,6 +300,24 @@ export function generateRequestId(span: OTelReadableSpan): string {
291
300
  return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
292
301
  }
293
302
 
303
+ /**
304
+ * Tags from span attributes: `llm.tag.<key>` (for example llm.tag.project) and the Vercel AI SDK's
305
+ * `ai.telemetry.metadata.tag.<key>`. Plain strings only; undefined when there are none.
306
+ */
307
+ export function extractTags(span: OTelReadableSpan): Record<string, string> | undefined {
308
+ const tags: Record<string, string> = {};
309
+ for (const [name, value] of Object.entries(span.attributes ?? {})) {
310
+ const key = name.startsWith("llm.tag.")
311
+ ? name.slice(8)
312
+ : name.startsWith("ai.telemetry.metadata.tag.")
313
+ ? name.slice(26)
314
+ : undefined;
315
+ if (!key || value === undefined || value === null || value === "") continue;
316
+ tags[key.slice(0, 64)] = String(value).slice(0, 200);
317
+ }
318
+ return Object.keys(tags).length ? tags : undefined;
319
+ }
320
+
294
321
  /**
295
322
  * Extract session ID from span attributes
296
323
  */
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { NodeLLMSpanProcessor } from "../src/NodeLLMSpanProcessor.js";
3
- import { MonitoringStore, MonitoringEvent } from "@node-llm/monitor";
3
+ import type { MonitoringStore, MonitoringEvent } from "@node-llm/monitor";
4
4
 
5
5
  describe("NodeLLMSpanProcessor", () => {
6
6
  let mockStore: MonitoringStore;
@@ -97,6 +97,37 @@ describe("NodeLLMSpanProcessor", () => {
97
97
  expect(event.payload.usage.reasoningTokens).toBe(15);
98
98
  });
99
99
 
100
+ it("should skip spans created by NodeLLM's OpenTelemetry middleware", async () => {
101
+ const span = createMockSpan("chat gpt-4o", {
102
+ attributes: {
103
+ "gen_ai.system": "openai",
104
+ "gen_ai.request.model": "gpt-4o",
105
+ "node_llm.request_id": "req-1"
106
+ }
107
+ });
108
+
109
+ processor.onEnd(span);
110
+ await processor.forceFlush();
111
+
112
+ expect(mockStore.saveEvent).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("should record NodeLLM spans when includeNodeLLMSpans is set", async () => {
116
+ processor = new NodeLLMSpanProcessor(mockStore, { includeNodeLLMSpans: true });
117
+ const span = createMockSpan("chat gpt-4o", {
118
+ attributes: {
119
+ "gen_ai.system": "openai",
120
+ "gen_ai.request.model": "gpt-4o",
121
+ "node_llm.request_id": "req-1"
122
+ }
123
+ });
124
+
125
+ processor.onEnd(span);
126
+ await processor.forceFlush();
127
+
128
+ expect(mockStore.saveEvent).toHaveBeenCalled();
129
+ });
130
+
100
131
  it("should ignore non-AI spans", async () => {
101
132
  const span = createMockSpan("http.get");
102
133
 
@@ -52,7 +52,7 @@ async function getFullEvent(
52
52
  async function getFirstEvent(store: MemoryAdapter): Promise<MonitoringEvent | undefined> {
53
53
  const traces = await getTraceSummaries(store);
54
54
  if (traces.length === 0) return undefined;
55
- return getFullEvent(store, traces[0].requestId);
55
+ return getFullEvent(store, traces[0]!.requestId);
56
56
  }
57
57
 
58
58
  describe("NodeLLMSpanProcessor Integration", () => {
@@ -71,7 +71,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
71
71
 
72
72
  const traces = await getTraceSummaries(store);
73
73
  expect(traces).toHaveLength(1);
74
- expect(traces[0].provider).toBe("openai");
74
+ expect(traces[0]!.provider).toBe("openai");
75
75
  });
76
76
 
77
77
  it("should normalize openai.chat to openai", async () => {
@@ -79,7 +79,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
79
79
  await processor.forceFlush();
80
80
 
81
81
  const traces = await getTraceSummaries(store);
82
- expect(traces[0].provider).toBe("openai");
82
+ expect(traces[0]!.provider).toBe("openai");
83
83
  });
84
84
 
85
85
  it("should normalize anthropic.messages to anthropic", async () => {
@@ -87,7 +87,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
87
87
  await processor.forceFlush();
88
88
 
89
89
  const traces = await getTraceSummaries(store);
90
- expect(traces[0].provider).toBe("anthropic");
90
+ expect(traces[0]!.provider).toBe("anthropic");
91
91
  });
92
92
 
93
93
  it("should normalize google.generativeai to google", async () => {
@@ -95,7 +95,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
95
95
  await processor.forceFlush();
96
96
 
97
97
  const traces = await getTraceSummaries(store);
98
- expect(traces[0].provider).toBe("google");
98
+ expect(traces[0]!.provider).toBe("google");
99
99
  });
100
100
  });
101
101
 
@@ -105,7 +105,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
105
105
  await processor.forceFlush();
106
106
 
107
107
  const traces = await getTraceSummaries(store);
108
- expect(traces[0].model).toBe("gpt-4o-mini");
108
+ expect(traces[0]!.model).toBe("gpt-4o-mini");
109
109
  });
110
110
 
111
111
  it("should extract correct model from Anthropic spans", async () => {
@@ -113,7 +113,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
113
113
  await processor.forceFlush();
114
114
 
115
115
  const traces = await getTraceSummaries(store);
116
- expect(traces[0].model).toBe("claude-3-5-sonnet-20241022");
116
+ expect(traces[0]!.model).toBe("claude-3-5-sonnet-20241022");
117
117
  });
118
118
 
119
119
  it("should extract correct model from genAi format spans", async () => {
@@ -121,7 +121,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
121
121
  await processor.forceFlush();
122
122
 
123
123
  const traces = await getTraceSummaries(store);
124
- expect(traces[0].model).toBe("claude-3-opus");
124
+ expect(traces[0]!.model).toBe("claude-3-opus");
125
125
  });
126
126
  });
127
127
 
@@ -354,7 +354,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
354
354
  await processor.forceFlush();
355
355
 
356
356
  const traces = await getTraceSummaries(store);
357
- expect(traces[0].model).toBe("text-embedding-3-small");
357
+ expect(traces[0]!.model).toBe("text-embedding-3-small");
358
358
 
359
359
  const event = await getFirstEvent(store);
360
360
  expect(event!.payload.operationType).toBe("ai.embed");
@@ -365,7 +365,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
365
365
  await processor.forceFlush();
366
366
 
367
367
  const traces = await getTraceSummaries(store);
368
- expect(traces[0].model).toBe("text-embedding-3-large");
368
+ expect(traces[0]!.model).toBe("text-embedding-3-large");
369
369
 
370
370
  const event = await getFirstEvent(store);
371
371
  expect(event!.payload.operationType).toBe("ai.embedMany");
@@ -379,8 +379,8 @@ describe("NodeLLMSpanProcessor Integration", () => {
379
379
 
380
380
  const traces = await getTraceSummaries(store);
381
381
  // Cost should be calculated based on token counts
382
- expect(traces[0].cost).toBeGreaterThan(0);
383
- expect(typeof traces[0].cost).toBe("number");
382
+ expect(traces[0]!.cost).toBeGreaterThan(0);
383
+ expect(typeof traces[0]!.cost).toBe("number");
384
384
  });
385
385
 
386
386
  it("should estimate cost for Anthropic Claude", async () => {
@@ -388,7 +388,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
388
388
  await processor.forceFlush();
389
389
 
390
390
  const traces = await getTraceSummaries(store);
391
- expect(traces[0].cost).toBeGreaterThan(0);
391
+ expect(traces[0]!.cost).toBeGreaterThan(0);
392
392
  });
393
393
  });
394
394
 
@@ -399,7 +399,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
399
399
 
400
400
  const traces = await getTraceSummaries(store);
401
401
  // Start: [1706918400, 0], End: [1706918401, 500000000] = 1500ms
402
- expect(traces[0].duration).toBe(1500);
402
+ expect(traces[0]!.duration).toBe(1500);
403
403
  });
404
404
 
405
405
  it("should calculate correct duration for streaming spans", async () => {
@@ -408,7 +408,7 @@ describe("NodeLLMSpanProcessor Integration", () => {
408
408
 
409
409
  const traces = await getTraceSummaries(store);
410
410
  // Start: [1706918500, 0], End: [1706918502, 340000000] = 2340ms
411
- expect(traces[0].duration).toBe(2340);
411
+ expect(traces[0]!.duration).toBe(2340);
412
412
  });
413
413
  });
414
414
 
@@ -13,7 +13,7 @@ import {
13
13
  normalizeModelName,
14
14
  normalizeProviderName
15
15
  } from "../src/utils.js";
16
- import { OTelReadableSpan } from "../src/types.js";
16
+ import type { OTelReadableSpan } from "../src/types.js";
17
17
 
18
18
  describe("otel utils", () => {
19
19
  const mockSpanContext = () => ({
@@ -308,3 +308,19 @@ describe("otel utils", () => {
308
308
  });
309
309
  });
310
310
  });
311
+
312
+ describe("extractTags", () => {
313
+ it("reads llm.tag.* and ai.telemetry.metadata.tag.* attributes as plain tags", async () => {
314
+ const { extractTags } = await import("../src/utils.js");
315
+ const span = {
316
+ attributes: {
317
+ "llm.tag.project": "Checkout",
318
+ "ai.telemetry.metadata.tag.env": "staging",
319
+ "llm.tag.empty": "",
320
+ other: "x"
321
+ }
322
+ } as any;
323
+ expect(extractTags(span)).toEqual({ project: "Checkout", env: "staging" });
324
+ expect(extractTags({ attributes: {} } as any)).toBeUndefined();
325
+ });
326
+ });