@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 +15 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +34 -5
- package/package.json +10 -10
- package/src/NodeLLMSpanProcessor.ts +25 -6
- package/src/index.ts +1 -1
- package/src/types.ts +44 -29
- package/src/utils.ts +32 -0
- package/test/NodeLLMSpanProcessor.test.ts +57 -1
- package/test/integration.test.ts +15 -15
- package/test/utils.test.ts +49 -1
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/test/integration.test.ts
CHANGED
|
@@ -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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
383
|
-
expect(typeof traces[0]
|
|
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]
|
|
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]
|
|
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]
|
|
411
|
+
expect(traces[0]!.duration).toBe(2340);
|
|
412
412
|
});
|
|
413
413
|
});
|
|
414
414
|
|
package/test/utils.test.ts
CHANGED
|
@@ -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
|
+
});
|