@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 +15 -0
- package/README.md +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +22 -4
- package/package.json +2 -2
- package/src/NodeLLMSpanProcessor.ts +18 -5
- package/src/index.ts +1 -1
- package/src/types.ts +43 -34
- package/src/utils.ts +27 -0
- package/test/NodeLLMSpanProcessor.test.ts +32 -1
- package/test/integration.test.ts +15 -15
- package/test/utils.test.ts +17 -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/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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
/**
|
|
@@ -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
|
|
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
|
|
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 = () => ({
|
|
@@ -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
|
+
});
|