@node-llm/monitor-otel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +325 -0
- package/package.json +26 -0
- package/src/NodeLLMSpanProcessor.ts +257 -0
- package/src/index.ts +1 -0
- package/src/types.ts +141 -0
- package/src/utils.ts +314 -0
- package/test/NodeLLMSpanProcessor.test.ts +165 -0
- package/test/fixtures/vercel-ai-spans.ts +379 -0
- package/test/integration.test.ts +432 -0
- package/test/utils.test.ts +278 -0
- package/tsconfig.json +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @node-llm/monitor-otel 📡
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
OpenTelemetry integration for NodeLLM Monitor. Bridging the gap between standard OTel tracing and AI-specific observability.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- 🔌 **Zero-Code Instrumentation**: Automatically capture AI spans from libraries like Vercel AI SDK.
|
|
11
|
+
- 🧠 **AI-Aware**: Extracts model names, token usage, cost, and tool calls from OTel attributes.
|
|
12
|
+
- 🎯 **Native Routing**: Forwards AI spans directly to your NodeLLM Monitor store.
|
|
13
|
+
- 🔍 **Streaming Support**: Captures Time-to-First-Token (TTFT) and average tokens/sec.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @node-llm/monitor @node-llm/monitor-otel
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### 1. Basic Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
27
|
+
import { Monitor } from "@node-llm/monitor";
|
|
28
|
+
import { NodeLLMSpanProcessor } from "@node-llm/monitor-otel";
|
|
29
|
+
|
|
30
|
+
// 1. Initialize your monitor store
|
|
31
|
+
const monitor = Monitor.memory();
|
|
32
|
+
|
|
33
|
+
// 2. Add the SpanProcessor to your OTel provider
|
|
34
|
+
const provider = new NodeTracerProvider();
|
|
35
|
+
provider.addSpanProcessor(new NodeLLMSpanProcessor(monitor.getStore()));
|
|
36
|
+
provider.register();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. Instrumented Libraries (e.g., Vercel AI SDK)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { generateText } from "ai";
|
|
43
|
+
import { openai } from "@ai-sdk/openai";
|
|
44
|
+
|
|
45
|
+
const result = await generateText({
|
|
46
|
+
model: openai("gpt-4o"),
|
|
47
|
+
prompt: "What is quantum gravity?",
|
|
48
|
+
experimental_telemetry: { isEnabled: true } // Enable OTel spans
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The spans emitted by `ai` will be automatically intercepted, processed, and saved to your NodeLLM Monitor dashboard.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
`NodeLLMSpanProcessor` accepts an optional options object:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
new NodeLLMSpanProcessor(store, {
|
|
60
|
+
captureContent: true, // Set to false to hide prompts/responses from logs
|
|
61
|
+
filter: (span) => span.name.includes("my-app"), // Custom span filtering
|
|
62
|
+
onError: (err, span) => console.error("Failed to process AI span", err)
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as _node_llm_monitor from '@node-llm/monitor';
|
|
2
|
+
import { MonitoringStore } from '@node-llm/monitor';
|
|
3
|
+
import { Span, Context } from '@opentelemetry/api';
|
|
4
|
+
import { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal Span interface matching OpenTelemetry ReadableSpan
|
|
8
|
+
*/
|
|
9
|
+
type OTelReadableSpan = ReadableSpan;
|
|
10
|
+
type OTelSpan = Span;
|
|
11
|
+
type OTelContext = Context;
|
|
12
|
+
/**
|
|
13
|
+
* SpanProcessor interface matching OpenTelemetry SDK
|
|
14
|
+
*/
|
|
15
|
+
type OTelSpanProcessor = SpanProcessor;
|
|
16
|
+
/**
|
|
17
|
+
* Configuration for the NodeLLM SpanProcessor
|
|
18
|
+
*/
|
|
19
|
+
interface NodeLLMSpanProcessorOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Whether to capture prompt/response content.
|
|
22
|
+
* Default: true (respects Vercel AI SDK's recordInputs/recordOutputs settings)
|
|
23
|
+
*/
|
|
24
|
+
captureContent?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Filter function to decide which spans to process.
|
|
27
|
+
* Return true to process, false to skip.
|
|
28
|
+
* Default: processes all ai.* spans
|
|
29
|
+
*/
|
|
30
|
+
filter?: (span: OTelReadableSpan) => boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Custom transform function to modify the monitoring event before saving.
|
|
33
|
+
*/
|
|
34
|
+
transform?: (event: _node_llm_monitor.MonitoringEvent, span: OTelReadableSpan) => _node_llm_monitor.MonitoringEvent;
|
|
35
|
+
/**
|
|
36
|
+
* Session ID extractor. By default, looks for ai.telemetry.metadata.sessionId
|
|
37
|
+
*/
|
|
38
|
+
sessionIdExtractor?: (span: OTelReadableSpan) => string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Error handler for storage failures
|
|
41
|
+
*/
|
|
42
|
+
onError?: (error: Error, span: OTelReadableSpan) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* NodeLLM SpanProcessor for OpenTelemetry
|
|
47
|
+
*
|
|
48
|
+
* This SpanProcessor intercepts OpenTelemetry spans emitted by the Vercel AI SDK
|
|
49
|
+
* (and other OTel-instrumented LLM libraries) and converts them to NodeLLM Monitor events.
|
|
50
|
+
*
|
|
51
|
+
* Usage:
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
54
|
+
* import { NodeLLMSpanProcessor } from "@node-llm/monitor/otel";
|
|
55
|
+
* import { PrismaAdapter } from "@node-llm/monitor/adapters/prisma";
|
|
56
|
+
*
|
|
57
|
+
* const store = new PrismaAdapter(prisma);
|
|
58
|
+
* const provider = new NodeTracerProvider();
|
|
59
|
+
* provider.addSpanProcessor(new NodeLLMSpanProcessor(store));
|
|
60
|
+
* provider.register();
|
|
61
|
+
*
|
|
62
|
+
* // Now Vercel AI SDK's experimental_telemetry will flow to node-llm-monitor
|
|
63
|
+
* await generateText({
|
|
64
|
+
* model: openai("gpt-4o"),
|
|
65
|
+
* prompt: "Hello",
|
|
66
|
+
* experimental_telemetry: { isEnabled: true }
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* OpenTelemetry SpanProcessor that routes AI spans to NodeLLM Monitor storage.
|
|
73
|
+
*
|
|
74
|
+
* This processor:
|
|
75
|
+
* 1. Filters for AI-related spans (ai.generateText, ai.streamText, etc.)
|
|
76
|
+
* 2. Extracts LLM-specific attributes (model, tokens, cost, etc.)
|
|
77
|
+
* 3. Converts to MonitoringEvent format
|
|
78
|
+
* 4. Saves to the configured MonitoringStore
|
|
79
|
+
*/
|
|
80
|
+
declare class NodeLLMSpanProcessor implements OTelSpanProcessor {
|
|
81
|
+
private readonly store;
|
|
82
|
+
private readonly options;
|
|
83
|
+
private pendingWrites;
|
|
84
|
+
constructor(store: MonitoringStore, options?: NodeLLMSpanProcessorOptions);
|
|
85
|
+
/**
|
|
86
|
+
* Called when a span starts. We don't need to do anything here
|
|
87
|
+
* since we process spans on completion.
|
|
88
|
+
*/
|
|
89
|
+
onStart(_span: OTelSpan, _parentContext: OTelContext): void;
|
|
90
|
+
/**
|
|
91
|
+
* Called when a span ends. This is where we extract data and save events.
|
|
92
|
+
*/
|
|
93
|
+
onEnd(span: OTelReadableSpan): void;
|
|
94
|
+
/**
|
|
95
|
+
* Process a single span and save as MonitoringEvent
|
|
96
|
+
*/
|
|
97
|
+
private processSpan;
|
|
98
|
+
/**
|
|
99
|
+
* Convert an OTel span to a MonitoringEvent
|
|
100
|
+
*/
|
|
101
|
+
private spanToEvent;
|
|
102
|
+
/**
|
|
103
|
+
* Flush all pending writes
|
|
104
|
+
*/
|
|
105
|
+
forceFlush(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Shutdown the processor
|
|
108
|
+
*/
|
|
109
|
+
shutdown(): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Factory function to create a SpanProcessor with simpler API
|
|
113
|
+
*/
|
|
114
|
+
declare function createOTelProcessor(store: MonitoringStore, options?: NodeLLMSpanProcessorOptions): NodeLLMSpanProcessor;
|
|
115
|
+
|
|
116
|
+
export { NodeLLMSpanProcessor, createOTelProcessor };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// src/NodeLLMSpanProcessor.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/utils.ts
|
|
5
|
+
var AI_OPERATION_PREFIXES = [
|
|
6
|
+
"ai.generateText",
|
|
7
|
+
"ai.streamText",
|
|
8
|
+
"ai.generateObject",
|
|
9
|
+
"ai.streamObject",
|
|
10
|
+
"ai.embed",
|
|
11
|
+
"ai.embedMany",
|
|
12
|
+
"ai.toolCall",
|
|
13
|
+
"gen_ai."
|
|
14
|
+
// Standard OTel prefix
|
|
15
|
+
];
|
|
16
|
+
function isAISpan(span) {
|
|
17
|
+
const hasAIPrefix = AI_OPERATION_PREFIXES.some((prefix) => span.name.startsWith(prefix));
|
|
18
|
+
const hasAIAttributes = span.attributes && (span.attributes["ai.operationId"] || span.attributes["gen_ai.system"] || span.attributes["gen_ai.request.model"]);
|
|
19
|
+
return !!(hasAIPrefix || hasAIAttributes);
|
|
20
|
+
}
|
|
21
|
+
function isTopLevelAISpan(span) {
|
|
22
|
+
const name = span.name;
|
|
23
|
+
const operationId = span.attributes["ai.operationId"];
|
|
24
|
+
const isDoSpan = name.includes(".doGenerate") || name.includes(".doStream") || name.includes(".doEmbed");
|
|
25
|
+
const isToolCall = operationId === "ai.toolCall" || name === "ai.toolCall";
|
|
26
|
+
return !isDoSpan || isToolCall;
|
|
27
|
+
}
|
|
28
|
+
function getOperationType(span) {
|
|
29
|
+
const operationId = span.attributes["ai.operationId"];
|
|
30
|
+
if (operationId) {
|
|
31
|
+
return operationId;
|
|
32
|
+
}
|
|
33
|
+
for (const prefix of AI_OPERATION_PREFIXES) {
|
|
34
|
+
if (span.name.startsWith(prefix)) {
|
|
35
|
+
return span.name.split(" ")[0];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
function getString(attrs, key) {
|
|
41
|
+
const value = attrs[key];
|
|
42
|
+
return typeof value === "string" ? value : void 0;
|
|
43
|
+
}
|
|
44
|
+
function getNumber(attrs, key) {
|
|
45
|
+
const value = attrs[key];
|
|
46
|
+
return typeof value === "number" ? value : void 0;
|
|
47
|
+
}
|
|
48
|
+
function extractAIAttributes(span) {
|
|
49
|
+
const attrs = span.attributes;
|
|
50
|
+
const metadata = {};
|
|
51
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
52
|
+
if (key.startsWith("ai.telemetry.metadata.")) {
|
|
53
|
+
const metaKey = key.replace("ai.telemetry.metadata.", "");
|
|
54
|
+
metadata[metaKey] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
// Operation info
|
|
59
|
+
operationId: getString(attrs, "ai.operationId"),
|
|
60
|
+
functionId: getString(attrs, "ai.telemetry.functionId") || getString(attrs, "resource.name"),
|
|
61
|
+
// Model info
|
|
62
|
+
modelId: getString(attrs, "ai.model.id"),
|
|
63
|
+
modelProvider: getString(attrs, "ai.model.provider"),
|
|
64
|
+
// Request info
|
|
65
|
+
prompt: getString(attrs, "ai.prompt"),
|
|
66
|
+
promptMessages: getString(attrs, "ai.prompt.messages"),
|
|
67
|
+
promptTools: getString(attrs, "ai.prompt.tools"),
|
|
68
|
+
// Response info
|
|
69
|
+
responseText: getString(attrs, "ai.response.text"),
|
|
70
|
+
responseObject: getString(attrs, "ai.response.object"),
|
|
71
|
+
responseToolCalls: getString(attrs, "ai.response.toolCalls"),
|
|
72
|
+
responseFinishReason: getString(attrs, "ai.response.finishReason"),
|
|
73
|
+
responseId: getString(attrs, "ai.response.id"),
|
|
74
|
+
responseModel: getString(attrs, "ai.response.model"),
|
|
75
|
+
// Usage - AI SDK attributes
|
|
76
|
+
promptTokens: getNumber(attrs, "ai.usage.promptTokens"),
|
|
77
|
+
completionTokens: getNumber(attrs, "ai.usage.completionTokens"),
|
|
78
|
+
// Timing (streaming)
|
|
79
|
+
msToFirstChunk: getNumber(attrs, "ai.response.msToFirstChunk"),
|
|
80
|
+
msToFinish: getNumber(attrs, "ai.response.msToFinish"),
|
|
81
|
+
avgCompletionTokensPerSecond: getNumber(attrs, "ai.response.avgCompletionTokensPerSecond"),
|
|
82
|
+
// Tool calls
|
|
83
|
+
toolCallName: getString(attrs, "ai.toolCall.name"),
|
|
84
|
+
toolCallId: getString(attrs, "ai.toolCall.id"),
|
|
85
|
+
toolCallArgs: getString(attrs, "ai.toolCall.args"),
|
|
86
|
+
toolCallResult: getString(attrs, "ai.toolCall.result"),
|
|
87
|
+
// GenAI semantic conventions (OpenTelemetry standard)
|
|
88
|
+
genAiSystem: getString(attrs, "gen_ai.system"),
|
|
89
|
+
genAiRequestModel: getString(attrs, "gen_ai.request.model"),
|
|
90
|
+
genAiResponseModel: getString(attrs, "gen_ai.response.model"),
|
|
91
|
+
genAiInputTokens: getNumber(attrs, "gen_ai.usage.input_tokens"),
|
|
92
|
+
genAiOutputTokens: getNumber(attrs, "gen_ai.usage.output_tokens"),
|
|
93
|
+
// Custom metadata
|
|
94
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : void 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function hrTimeToDate(hrTime) {
|
|
98
|
+
const [seconds, nanoseconds] = hrTime;
|
|
99
|
+
return new Date(seconds * 1e3 + nanoseconds / 1e6);
|
|
100
|
+
}
|
|
101
|
+
function calculateDurationMs(startTime, endTime) {
|
|
102
|
+
const startMs = startTime[0] * 1e3 + startTime[1] / 1e6;
|
|
103
|
+
const endMs = endTime[0] * 1e3 + endTime[1] / 1e6;
|
|
104
|
+
return Math.round(endMs - startMs);
|
|
105
|
+
}
|
|
106
|
+
function extractProvider(attrs) {
|
|
107
|
+
if (attrs.modelProvider) {
|
|
108
|
+
return normalizeProviderName(attrs.modelProvider);
|
|
109
|
+
}
|
|
110
|
+
if (attrs.genAiSystem) {
|
|
111
|
+
return normalizeProviderName(attrs.genAiSystem);
|
|
112
|
+
}
|
|
113
|
+
const modelId = attrs.modelId || attrs.genAiRequestModel || "";
|
|
114
|
+
if (modelId.startsWith("gpt-") || modelId.includes("openai")) {
|
|
115
|
+
return "openai";
|
|
116
|
+
}
|
|
117
|
+
if (modelId.startsWith("claude-") || modelId.includes("anthropic")) {
|
|
118
|
+
return "anthropic";
|
|
119
|
+
}
|
|
120
|
+
if (modelId.startsWith("gemini-") || modelId.includes("google")) {
|
|
121
|
+
return "google";
|
|
122
|
+
}
|
|
123
|
+
if (modelId.startsWith("deepseek-")) {
|
|
124
|
+
return "deepseek";
|
|
125
|
+
}
|
|
126
|
+
if (modelId.includes("llama") || modelId.includes("mistral")) {
|
|
127
|
+
return "meta";
|
|
128
|
+
}
|
|
129
|
+
return "unknown";
|
|
130
|
+
}
|
|
131
|
+
function normalizeProviderName(provider) {
|
|
132
|
+
if (provider.includes(".")) {
|
|
133
|
+
return provider.split(".")[0] || provider;
|
|
134
|
+
}
|
|
135
|
+
return provider;
|
|
136
|
+
}
|
|
137
|
+
function extractModel(attrs) {
|
|
138
|
+
const rawModel = attrs.responseModel || attrs.modelId || attrs.genAiResponseModel || attrs.genAiRequestModel || "unknown";
|
|
139
|
+
return normalizeModelName(rawModel);
|
|
140
|
+
}
|
|
141
|
+
function normalizeModelName(model) {
|
|
142
|
+
if (model.includes("/")) {
|
|
143
|
+
const parts = model.split("/");
|
|
144
|
+
return parts[parts.length - 1] || model;
|
|
145
|
+
}
|
|
146
|
+
return model;
|
|
147
|
+
}
|
|
148
|
+
function mapStatusToEventType(statusCode, operationType) {
|
|
149
|
+
const isError = statusCode === 2;
|
|
150
|
+
if (operationType?.includes("toolCall")) {
|
|
151
|
+
return isError ? "tool.error" : "tool.end";
|
|
152
|
+
}
|
|
153
|
+
return isError ? "request.error" : "request.end";
|
|
154
|
+
}
|
|
155
|
+
function generateRequestId(span) {
|
|
156
|
+
const ctx = span.spanContext();
|
|
157
|
+
return `otel_${ctx.traceId.slice(0, 8)}_${ctx.spanId.slice(0, 8)}`;
|
|
158
|
+
}
|
|
159
|
+
function extractSessionId(span) {
|
|
160
|
+
const attrs = span.attributes;
|
|
161
|
+
return getString(attrs, "ai.telemetry.metadata.sessionId") || getString(attrs, "ai.telemetry.metadata.session_id") || getString(attrs, "session.id") || getString(attrs, "sessionId");
|
|
162
|
+
}
|
|
163
|
+
function parseJsonAttribute(value) {
|
|
164
|
+
if (!value) return void 0;
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(value);
|
|
167
|
+
} catch {
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/NodeLLMSpanProcessor.ts
|
|
173
|
+
import { estimateCost } from "@node-llm/monitor";
|
|
174
|
+
var NodeLLMSpanProcessor = class {
|
|
175
|
+
store;
|
|
176
|
+
options;
|
|
177
|
+
pendingWrites = [];
|
|
178
|
+
constructor(store, options = {}) {
|
|
179
|
+
this.store = store;
|
|
180
|
+
this.options = {
|
|
181
|
+
captureContent: true,
|
|
182
|
+
...options
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Called when a span starts. We don't need to do anything here
|
|
187
|
+
* since we process spans on completion.
|
|
188
|
+
*/
|
|
189
|
+
onStart(_span, _parentContext) {
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Called when a span ends. This is where we extract data and save events.
|
|
193
|
+
*/
|
|
194
|
+
onEnd(span) {
|
|
195
|
+
if (!isAISpan(span)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.options.filter && !this.options.filter(span)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!isTopLevelAISpan(span)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const writePromise = this.processSpan(span).catch((error) => {
|
|
205
|
+
if (this.options.onError) {
|
|
206
|
+
this.options.onError(error, span);
|
|
207
|
+
} else {
|
|
208
|
+
console.error("[NodeLLMSpanProcessor] Error processing span:", error);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
this.pendingWrites.push(writePromise);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Process a single span and save as MonitoringEvent
|
|
215
|
+
*/
|
|
216
|
+
async processSpan(span) {
|
|
217
|
+
const event = this.spanToEvent(span);
|
|
218
|
+
const finalEvent = this.options.transform ? this.options.transform(event, span) : event;
|
|
219
|
+
await this.store.saveEvent(finalEvent);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Convert an OTel span to a MonitoringEvent
|
|
223
|
+
*/
|
|
224
|
+
spanToEvent(span) {
|
|
225
|
+
const attrs = extractAIAttributes(span);
|
|
226
|
+
const operationType = getOperationType(span);
|
|
227
|
+
const provider = extractProvider(attrs);
|
|
228
|
+
const model = extractModel(attrs);
|
|
229
|
+
const duration = calculateDurationMs(span.startTime, span.endTime);
|
|
230
|
+
const promptTokens = attrs.promptTokens ?? attrs.genAiInputTokens ?? 0;
|
|
231
|
+
const completionTokens = attrs.completionTokens ?? attrs.genAiOutputTokens ?? 0;
|
|
232
|
+
const cost = estimateCost(provider, model, promptTokens, completionTokens);
|
|
233
|
+
const requestId = this.options.sessionIdExtractor ? generateRequestId(span) : generateRequestId(span);
|
|
234
|
+
const sessionId = this.options.sessionIdExtractor ? this.options.sessionIdExtractor(span) : extractSessionId(span);
|
|
235
|
+
const payload = {
|
|
236
|
+
operationType,
|
|
237
|
+
functionId: attrs.functionId,
|
|
238
|
+
finishReason: attrs.responseFinishReason,
|
|
239
|
+
usage: {
|
|
240
|
+
promptTokens,
|
|
241
|
+
completionTokens,
|
|
242
|
+
totalTokens: promptTokens + completionTokens
|
|
243
|
+
},
|
|
244
|
+
// Streaming metrics
|
|
245
|
+
...attrs.msToFirstChunk !== void 0 && {
|
|
246
|
+
msToFirstChunk: attrs.msToFirstChunk
|
|
247
|
+
},
|
|
248
|
+
...attrs.msToFinish !== void 0 && { msToFinish: attrs.msToFinish },
|
|
249
|
+
...attrs.avgCompletionTokensPerSecond !== void 0 && {
|
|
250
|
+
avgCompletionTokensPerSecond: attrs.avgCompletionTokensPerSecond
|
|
251
|
+
},
|
|
252
|
+
// Custom metadata
|
|
253
|
+
...attrs.metadata && { metadata: attrs.metadata },
|
|
254
|
+
// OpenTelemetry trace context
|
|
255
|
+
otel: {
|
|
256
|
+
traceId: span.spanContext().traceId,
|
|
257
|
+
spanId: span.spanContext().spanId,
|
|
258
|
+
parentSpanId: span.parentSpanId
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
if (this.options.captureContent) {
|
|
262
|
+
if (attrs.prompt) {
|
|
263
|
+
payload.prompt = attrs.prompt;
|
|
264
|
+
}
|
|
265
|
+
if (attrs.promptMessages) {
|
|
266
|
+
payload.messages = parseJsonAttribute(attrs.promptMessages);
|
|
267
|
+
}
|
|
268
|
+
if (attrs.responseText) {
|
|
269
|
+
payload.result = attrs.responseText;
|
|
270
|
+
}
|
|
271
|
+
if (attrs.responseObject) {
|
|
272
|
+
payload.object = parseJsonAttribute(attrs.responseObject);
|
|
273
|
+
}
|
|
274
|
+
if (attrs.responseToolCalls) {
|
|
275
|
+
payload.toolCalls = parseJsonAttribute(attrs.responseToolCalls);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (operationType === "ai.toolCall") {
|
|
279
|
+
payload.tool = {
|
|
280
|
+
name: attrs.toolCallName,
|
|
281
|
+
id: attrs.toolCallId,
|
|
282
|
+
...this.options.captureContent && {
|
|
283
|
+
args: parseJsonAttribute(attrs.toolCallArgs),
|
|
284
|
+
result: parseJsonAttribute(attrs.toolCallResult)
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (span.status.code === 2) {
|
|
289
|
+
payload.error = span.status.message || "Unknown error";
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
id: randomUUID(),
|
|
293
|
+
eventType: mapStatusToEventType(span.status.code, operationType),
|
|
294
|
+
requestId,
|
|
295
|
+
sessionId,
|
|
296
|
+
time: hrTimeToDate(span.startTime),
|
|
297
|
+
duration,
|
|
298
|
+
cost,
|
|
299
|
+
payload,
|
|
300
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
301
|
+
provider,
|
|
302
|
+
model
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Flush all pending writes
|
|
307
|
+
*/
|
|
308
|
+
async forceFlush() {
|
|
309
|
+
await Promise.all(this.pendingWrites);
|
|
310
|
+
this.pendingWrites = [];
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Shutdown the processor
|
|
314
|
+
*/
|
|
315
|
+
async shutdown() {
|
|
316
|
+
await this.forceFlush();
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
function createOTelProcessor(store, options) {
|
|
320
|
+
return new NodeLLMSpanProcessor(store, options);
|
|
321
|
+
}
|
|
322
|
+
export {
|
|
323
|
+
NodeLLMSpanProcessor,
|
|
324
|
+
createOTelProcessor
|
|
325
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@node-llm/monitor-otel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenTelemetry integration for NodeLLM Monitor",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
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
|
+
"dependencies": {
|
|
15
|
+
"@node-llm/monitor": "workspace:*",
|
|
16
|
+
"@opentelemetry/api": "^1.7.0",
|
|
17
|
+
"@opentelemetry/sdk-trace-base": "^1.18.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^25.2.0",
|
|
21
|
+
"eslint": "^8.56.0",
|
|
22
|
+
"tsup": "^8.0.1",
|
|
23
|
+
"typescript": "^5.3.3",
|
|
24
|
+
"vitest": "^4.0.18"
|
|
25
|
+
}
|
|
26
|
+
}
|