@node-llm/monitor-otel 0.1.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NodeLLM contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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"];
@@ -75,6 +78,8 @@ function extractAIAttributes(span) {
75
78
  // Usage - AI SDK attributes
76
79
  promptTokens: getNumber(attrs, "ai.usage.promptTokens"),
77
80
  completionTokens: getNumber(attrs, "ai.usage.completionTokens"),
81
+ cachedInputTokens: getNumber(attrs, "ai.usage.cachedInputTokens"),
82
+ reasoningTokens: getNumber(attrs, "ai.usage.reasoningTokens"),
78
83
  // Timing (streaming)
79
84
  msToFirstChunk: getNumber(attrs, "ai.response.msToFirstChunk"),
80
85
  msToFinish: getNumber(attrs, "ai.response.msToFinish"),
@@ -90,6 +95,9 @@ function extractAIAttributes(span) {
90
95
  genAiResponseModel: getString(attrs, "gen_ai.response.model"),
91
96
  genAiInputTokens: getNumber(attrs, "gen_ai.usage.input_tokens"),
92
97
  genAiOutputTokens: getNumber(attrs, "gen_ai.usage.output_tokens"),
98
+ genAiCacheReadInputTokens: getNumber(attrs, "gen_ai.usage.cache_read.input_tokens"),
99
+ genAiCacheCreationInputTokens: getNumber(attrs, "gen_ai.usage.cache_creation.input_tokens"),
100
+ genAiReasoningOutputTokens: getNumber(attrs, "gen_ai.usage.reasoning.output_tokens"),
93
101
  // Custom metadata
94
102
  metadata: Object.keys(metadata).length > 0 ? metadata : void 0
95
103
  };
@@ -156,6 +164,15 @@ function generateRequestId(span) {
156
164
  const ctx = span.spanContext();
157
165
  return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
158
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
+ }
159
176
  function extractSessionId(span) {
160
177
  const attrs = span.attributes;
161
178
  return getString(attrs, "ai.telemetry.metadata.sessionId") || getString(attrs, "ai.telemetry.metadata.session_id") || getString(attrs, "session.id") || getString(attrs, "sessionId");
@@ -174,7 +191,7 @@ import { estimateCost } from "@node-llm/monitor";
174
191
  var NodeLLMSpanProcessor = class {
175
192
  store;
176
193
  options;
177
- pendingWrites = [];
194
+ pendingWrites = /* @__PURE__ */ new Set();
178
195
  constructor(store, options = {}) {
179
196
  this.store = store;
180
197
  this.options = {
@@ -195,6 +212,9 @@ var NodeLLMSpanProcessor = class {
195
212
  if (!isAISpan(span)) {
196
213
  return;
197
214
  }
215
+ if (!this.options.includeNodeLLMSpans && isNodeLLMSpan(span)) {
216
+ return;
217
+ }
198
218
  if (this.options.filter && !this.options.filter(span)) {
199
219
  return;
200
220
  }
@@ -208,7 +228,8 @@ var NodeLLMSpanProcessor = class {
208
228
  console.error("[NodeLLMSpanProcessor] Error processing span:", error);
209
229
  }
210
230
  });
211
- this.pendingWrites.push(writePromise);
231
+ this.pendingWrites.add(writePromise);
232
+ void writePromise.finally(() => this.pendingWrites.delete(writePromise));
212
233
  }
213
234
  /**
214
235
  * Process a single span and save as MonitoringEvent
@@ -229,6 +250,9 @@ var NodeLLMSpanProcessor = class {
229
250
  const duration = calculateDurationMs(span.startTime, span.endTime);
230
251
  const promptTokens = attrs.promptTokens ?? attrs.genAiInputTokens ?? 0;
231
252
  const completionTokens = attrs.completionTokens ?? attrs.genAiOutputTokens ?? 0;
253
+ const cachedTokens = attrs.cachedInputTokens ?? attrs.genAiCacheReadInputTokens ?? 0;
254
+ const cacheCreationTokens = attrs.genAiCacheCreationInputTokens ?? 0;
255
+ const reasoningTokens = attrs.reasoningTokens ?? attrs.genAiReasoningOutputTokens ?? 0;
232
256
  const cost = estimateCost(provider, model, promptTokens, completionTokens);
233
257
  const requestId = this.options.sessionIdExtractor ? generateRequestId(span) : generateRequestId(span);
234
258
  const sessionId = this.options.sessionIdExtractor ? this.options.sessionIdExtractor(span) : extractSessionId(span);
@@ -239,7 +263,10 @@ var NodeLLMSpanProcessor = class {
239
263
  usage: {
240
264
  promptTokens,
241
265
  completionTokens,
242
- totalTokens: promptTokens + completionTokens
266
+ totalTokens: promptTokens + completionTokens,
267
+ ...cachedTokens > 0 && { cachedTokens },
268
+ ...cacheCreationTokens > 0 && { cacheCreationTokens },
269
+ ...reasoningTokens > 0 && { reasoningTokens }
243
270
  },
244
271
  // Streaming metrics
245
272
  ...attrs.msToFirstChunk !== void 0 && {
@@ -285,6 +312,7 @@ var NodeLLMSpanProcessor = class {
285
312
  }
286
313
  };
287
314
  }
315
+ const tags = extractTags(span);
288
316
  if (span.status.code === 2) {
289
317
  payload.error = span.status.message || "Unknown error";
290
318
  }
@@ -299,7 +327,8 @@ var NodeLLMSpanProcessor = class {
299
327
  payload,
300
328
  createdAt: /* @__PURE__ */ new Date(),
301
329
  provider,
302
- model
330
+ model,
331
+ ...tags ? { tags } : {}
303
332
  };
304
333
  }
305
334
  /**
@@ -307,7 +336,7 @@ var NodeLLMSpanProcessor = class {
307
336
  */
308
337
  async forceFlush() {
309
338
  await Promise.all(this.pendingWrites);
310
- this.pendingWrites = [];
339
+ this.pendingWrites.clear();
311
340
  }
312
341
  /**
313
342
  * Shutdown the processor
package/package.json CHANGED
@@ -1,20 +1,14 @@
1
1
  {
2
2
  "name": "@node-llm/monitor-otel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenTelemetry integration for NodeLLM Monitor",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "types": "dist/index.d.ts",
8
- "scripts": {
9
- "build": "tsup src/index.ts --format esm --dts --clean",
10
- "dev": "tsup src/index.ts --format esm --dts --watch",
11
- "lint": "eslint .",
12
- "test": "vitest run"
13
- },
14
8
  "dependencies": {
15
- "@node-llm/monitor": "workspace:*",
16
9
  "@opentelemetry/api": "^1.7.0",
17
- "@opentelemetry/sdk-trace-base": "^1.18.1"
10
+ "@opentelemetry/sdk-trace-base": "^1.18.1",
11
+ "@node-llm/monitor": "0.5.0"
18
12
  },
19
13
  "devDependencies": {
20
14
  "@types/node": "^25.2.0",
@@ -22,5 +16,11 @@
22
16
  "tsup": "^8.0.1",
23
17
  "typescript": "^5.3.3",
24
18
  "vitest": "^4.0.18"
19
+ },
20
+ "scripts": {
21
+ "build": "tsup src/index.ts --format esm --dts --clean",
22
+ "dev": "tsup src/index.ts --format esm --dts --watch",
23
+ "lint": "eslint .",
24
+ "test": "vitest run"
25
25
  }
26
- }
26
+ }
@@ -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
  /**
@@ -137,6 +147,9 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
137
147
  // Calculate token counts (prefer AI SDK attributes, fallback to GenAI conventions)
138
148
  const promptTokens = attrs.promptTokens ?? attrs.genAiInputTokens ?? 0;
139
149
  const completionTokens = attrs.completionTokens ?? attrs.genAiOutputTokens ?? 0;
150
+ const cachedTokens = attrs.cachedInputTokens ?? attrs.genAiCacheReadInputTokens ?? 0;
151
+ const cacheCreationTokens = attrs.genAiCacheCreationInputTokens ?? 0;
152
+ const reasoningTokens = attrs.reasoningTokens ?? attrs.genAiReasoningOutputTokens ?? 0;
140
153
 
141
154
  // Estimate cost
142
155
  const cost = estimateCost(provider, model, promptTokens, completionTokens);
@@ -159,7 +172,10 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
159
172
  usage: {
160
173
  promptTokens,
161
174
  completionTokens,
162
- totalTokens: promptTokens + completionTokens
175
+ totalTokens: promptTokens + completionTokens,
176
+ ...(cachedTokens > 0 && { cachedTokens }),
177
+ ...(cacheCreationTokens > 0 && { cacheCreationTokens }),
178
+ ...(reasoningTokens > 0 && { reasoningTokens })
163
179
  },
164
180
  // Streaming metrics
165
181
  ...(attrs.msToFirstChunk !== undefined && {
@@ -210,6 +226,8 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
210
226
  };
211
227
  }
212
228
 
229
+ const tags = extractTags(span);
230
+
213
231
  // Handle errors
214
232
  if (span.status.code === 2) {
215
233
  payload.error = span.status.message || "Unknown error";
@@ -226,7 +244,8 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
226
244
  payload,
227
245
  createdAt: new Date(),
228
246
  provider,
229
- model
247
+ model,
248
+ ...(tags ? { tags } : {})
230
249
  };
231
250
  }
232
251
 
@@ -235,7 +254,7 @@ export class NodeLLMSpanProcessor implements OTelSpanProcessor {
235
254
  */
236
255
  async forceFlush(): Promise<void> {
237
256
  await Promise.all(this.pendingWrites);
238
- this.pendingWrites = [];
257
+ this.pendingWrites.clear();
239
258
  }
240
259
 
241
260
  /**
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export * from './NodeLLMSpanProcessor.js';
1
+ export * from "./NodeLLMSpanProcessor.js";
package/src/types.ts CHANGED
@@ -57,51 +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
+ // Cache/reasoning usage (forward-compatible AI SDK attributes - not yet
85
+ cachedInputTokens?: number | undefined;
86
+ reasoningTokens?: number | undefined;
84
87
 
85
88
  // Timing (streaming)
86
- msToFirstChunk?: number;
87
- msToFinish?: number;
88
- avgCompletionTokensPerSecond?: number;
89
+ msToFirstChunk?: number | undefined;
90
+ msToFinish?: number | undefined;
91
+ avgCompletionTokensPerSecond?: number | undefined;
89
92
 
90
93
  // Tool calls
91
- toolCallName?: string;
92
- toolCallId?: string;
93
- toolCallArgs?: string;
94
- toolCallResult?: string;
94
+ toolCallName?: string | undefined;
95
+ toolCallId?: string | undefined;
96
+ toolCallArgs?: string | undefined;
97
+ toolCallResult?: string | undefined;
95
98
 
96
99
  // GenAI semantic conventions
97
- genAiSystem?: string;
98
- genAiRequestModel?: string;
99
- genAiResponseModel?: string;
100
- genAiInputTokens?: number;
101
- genAiOutputTokens?: 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;
102
108
 
103
109
  // Custom metadata
104
- metadata?: Record<string, unknown>;
110
+ metadata?: Record<string, unknown> | undefined;
105
111
  }
106
112
 
107
113
  /**
@@ -134,6 +140,15 @@ export interface NodeLLMSpanProcessorOptions {
134
140
  */
135
141
  sessionIdExtractor?: (span: OTelReadableSpan) => string | undefined;
136
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
+
137
152
  /**
138
153
  * Error handler for storage failures
139
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
  */
@@ -131,6 +140,8 @@ export function extractAIAttributes(span: OTelReadableSpan): AISpanAttributes {
131
140
  // Usage - AI SDK attributes
132
141
  promptTokens: getNumber(attrs, "ai.usage.promptTokens"),
133
142
  completionTokens: getNumber(attrs, "ai.usage.completionTokens"),
143
+ cachedInputTokens: getNumber(attrs, "ai.usage.cachedInputTokens"),
144
+ reasoningTokens: getNumber(attrs, "ai.usage.reasoningTokens"),
134
145
 
135
146
  // Timing (streaming)
136
147
  msToFirstChunk: getNumber(attrs, "ai.response.msToFirstChunk"),
@@ -149,6 +160,9 @@ export function extractAIAttributes(span: OTelReadableSpan): AISpanAttributes {
149
160
  genAiResponseModel: getString(attrs, "gen_ai.response.model"),
150
161
  genAiInputTokens: getNumber(attrs, "gen_ai.usage.input_tokens"),
151
162
  genAiOutputTokens: getNumber(attrs, "gen_ai.usage.output_tokens"),
163
+ genAiCacheReadInputTokens: getNumber(attrs, "gen_ai.usage.cache_read.input_tokens"),
164
+ genAiCacheCreationInputTokens: getNumber(attrs, "gen_ai.usage.cache_creation.input_tokens"),
165
+ genAiReasoningOutputTokens: getNumber(attrs, "gen_ai.usage.reasoning.output_tokens"),
152
166
 
153
167
  // Custom metadata
154
168
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined
@@ -286,6 +300,24 @@ export function generateRequestId(span: OTelReadableSpan): string {
286
300
  return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
287
301
  }
288
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
+
289
321
  /**
290
322
  * Extract session ID from span attributes
291
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;
@@ -72,6 +72,62 @@ describe("NodeLLMSpanProcessor", () => {
72
72
  expect(event.payload.usage.totalTokens).toBe(225);
73
73
  });
74
74
 
75
+ it("should include cache/reasoning token usage from GenAI spans", async () => {
76
+ const span = createMockSpan("gen_ai.chat", {
77
+ attributes: {
78
+ "ai.model.id": undefined,
79
+ "ai.model.provider": undefined,
80
+ "gen_ai.system": "anthropic",
81
+ "gen_ai.request.model": "claude-3-5-sonnet",
82
+ "gen_ai.usage.input_tokens": 150,
83
+ "gen_ai.usage.output_tokens": 75,
84
+ "gen_ai.usage.cache_read.input_tokens": 100,
85
+ "gen_ai.usage.cache_creation.input_tokens": 20,
86
+ "gen_ai.usage.reasoning.output_tokens": 15
87
+ }
88
+ });
89
+
90
+ processor.onEnd(span);
91
+ await processor.forceFlush();
92
+
93
+ const event = (mockStore.saveEvent as any).mock.calls[0][0];
94
+
95
+ expect(event.payload.usage.cachedTokens).toBe(100);
96
+ expect(event.payload.usage.cacheCreationTokens).toBe(20);
97
+ expect(event.payload.usage.reasoningTokens).toBe(15);
98
+ });
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
+
75
131
  it("should ignore non-AI spans", async () => {
76
132
  const span = createMockSpan("http.get");
77
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 = () => ({
@@ -106,6 +106,38 @@ describe("otel utils", () => {
106
106
  expect(attrs.genAiOutputTokens).toBe(25);
107
107
  });
108
108
 
109
+ it("should extract OTel GenAI cache/reasoning token attributes", () => {
110
+ const span = {
111
+ attributes: {
112
+ "gen_ai.usage.input_tokens": 100,
113
+ "gen_ai.usage.output_tokens": 50,
114
+ "gen_ai.usage.cache_read.input_tokens": 75,
115
+ "gen_ai.usage.cache_creation.input_tokens": 25,
116
+ "gen_ai.usage.reasoning.output_tokens": 10
117
+ }
118
+ } as any;
119
+
120
+ const attrs = extractAIAttributes(span);
121
+ expect(attrs.genAiCacheReadInputTokens).toBe(75);
122
+ expect(attrs.genAiCacheCreationInputTokens).toBe(25);
123
+ expect(attrs.genAiReasoningOutputTokens).toBe(10);
124
+ });
125
+
126
+ it("should extract Vercel AI SDK cache/reasoning token attributes", () => {
127
+ const span = {
128
+ attributes: {
129
+ "ai.usage.promptTokens": 100,
130
+ "ai.usage.completionTokens": 50,
131
+ "ai.usage.cachedInputTokens": 60,
132
+ "ai.usage.reasoningTokens": 12
133
+ }
134
+ } as any;
135
+
136
+ const attrs = extractAIAttributes(span);
137
+ expect(attrs.cachedInputTokens).toBe(60);
138
+ expect(attrs.reasoningTokens).toBe(12);
139
+ });
140
+
109
141
  it("should extract custom metadata", () => {
110
142
  const span = {
111
143
  attributes: {
@@ -276,3 +308,19 @@ describe("otel utils", () => {
276
308
  });
277
309
  });
278
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
+ });