@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 ADDED
@@ -0,0 +1,68 @@
1
+ # @node-llm/monitor-otel 📡
2
+
3
+ ![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-Enabled-blue)
4
+ ![npm](https://img.shields.io/npm/v/@node-llm/monitor-otel)
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
@@ -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
+ }