@lunardinos/server 0.2.3 → 0.2.5
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/dist/index.js
CHANGED
|
@@ -102,8 +102,8 @@ class LunarDinosSpanProcessor {
|
|
|
102
102
|
return;
|
|
103
103
|
const traceId = span.spanContext().traceId;
|
|
104
104
|
const model = strAttr(span, "ai.model.id") ?? strAttr(span, "gen_ai.request.model");
|
|
105
|
-
const inputTokens = numAttr(span, "gen_ai.usage.input_tokens") || numAttr(span, "ai.usage.promptTokens");
|
|
106
|
-
const outputTokens = numAttr(span, "gen_ai.usage.output_tokens") || numAttr(span, "ai.usage.completionTokens");
|
|
105
|
+
const inputTokens = numAttr(span, "ai.usage.inputTokens") || numAttr(span, "gen_ai.usage.input_tokens") || numAttr(span, "ai.usage.promptTokens");
|
|
106
|
+
const outputTokens = numAttr(span, "ai.usage.outputTokens") || numAttr(span, "gen_ai.usage.output_tokens") || numAttr(span, "ai.usage.completionTokens");
|
|
107
107
|
const startMs = hrtimeToMs(span.startTime);
|
|
108
108
|
const endMs = hrtimeToMs(span.endTime);
|
|
109
109
|
const childToolCalls = this.toolCallSpans.get(traceId);
|
|
@@ -124,7 +124,7 @@ class LunarDinosSpanProcessor {
|
|
|
124
124
|
durationMs: Math.round(endMs - startMs),
|
|
125
125
|
msToFirstChunk: numAttr(span, "ai.response.msToFirstChunk") || null,
|
|
126
126
|
operation: span.name.replace("ai.", ""),
|
|
127
|
-
finishReason:
|
|
127
|
+
finishReason: finishReason(span),
|
|
128
128
|
error: span.status?.code === 2,
|
|
129
129
|
toolCalls,
|
|
130
130
|
prompt: this.config.recordPrompts ? jsonAttr(span, "ai.prompt.messages") : null,
|
|
@@ -179,6 +179,15 @@ class LunarDinosSpanProcessor {
|
|
|
179
179
|
function hrtimeToMs(hrtime) {
|
|
180
180
|
return hrtime[0] * 1000 + hrtime[1] / 1e6;
|
|
181
181
|
}
|
|
182
|
+
function finishReason(span) {
|
|
183
|
+
const direct = strAttr(span, "ai.response.finishReason");
|
|
184
|
+
if (direct)
|
|
185
|
+
return direct;
|
|
186
|
+
const reasons = attr(span, "gen_ai.response.finish_reasons");
|
|
187
|
+
if (Array.isArray(reasons) && typeof reasons[0] === "string")
|
|
188
|
+
return reasons[0];
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
182
191
|
|
|
183
192
|
// src/init.ts
|
|
184
193
|
function initLunarDinos(config) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"processor.d.ts","sourceRoot":"","sources":["../../src/processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAGhF,MAAM,WAAW,gBAAgB;IAC/B,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4EAA4E;IAC5E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AA8FD,qBAAa,uBAAwB,YAAW,aAAa;IAO/C,OAAO,CAAC,MAAM;IAN1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,aAAa,CAAoC;gBAErC,MAAM,EAAE,gBAAgB;IAS5C,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI;IAEhD,KAAK,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI;
|
|
1
|
+
{"version":3,"file":"processor.d.ts","sourceRoot":"","sources":["../../src/processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAGhF,MAAM,WAAW,gBAAgB;IAC/B,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4EAA4E;IAC5E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AA8FD,qBAAa,uBAAwB,YAAW,aAAa;IAO/C,OAAO,CAAC,MAAM;IAN1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,aAAa,CAAoC;gBAErC,MAAM,EAAE,gBAAgB;IAS5C,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI;IAEhD,KAAK,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI;IA4FzB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;YAQjB,KAAK;CAepB"}
|
package/dist/src/processor.js
CHANGED
|
@@ -83,8 +83,16 @@ export class LunarDinosSpanProcessor {
|
|
|
83
83
|
return;
|
|
84
84
|
const traceId = span.spanContext().traceId;
|
|
85
85
|
const model = strAttr(span, "ai.model.id") ?? strAttr(span, "gen_ai.request.model");
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Root AI SDK spans (v5+) carry `ai.usage.inputTokens`/`outputTokens`. The
|
|
87
|
+
// older `ai.usage.promptTokens`/`completionTokens` and the `gen_ai.usage.*`
|
|
88
|
+
// names only appear on the child doGenerate/doStream spans we skip — keep
|
|
89
|
+
// them as fallbacks for older SDKs / alternative instrumentations.
|
|
90
|
+
const inputTokens = numAttr(span, "ai.usage.inputTokens") ||
|
|
91
|
+
numAttr(span, "gen_ai.usage.input_tokens") ||
|
|
92
|
+
numAttr(span, "ai.usage.promptTokens");
|
|
93
|
+
const outputTokens = numAttr(span, "ai.usage.outputTokens") ||
|
|
94
|
+
numAttr(span, "gen_ai.usage.output_tokens") ||
|
|
95
|
+
numAttr(span, "ai.usage.completionTokens");
|
|
88
96
|
const startMs = hrtimeToMs(span.startTime);
|
|
89
97
|
const endMs = hrtimeToMs(span.endTime);
|
|
90
98
|
const childToolCalls = this.toolCallSpans.get(traceId);
|
|
@@ -105,7 +113,7 @@ export class LunarDinosSpanProcessor {
|
|
|
105
113
|
durationMs: Math.round(endMs - startMs),
|
|
106
114
|
msToFirstChunk: numAttr(span, "ai.response.msToFirstChunk") || null,
|
|
107
115
|
operation: span.name.replace("ai.", ""),
|
|
108
|
-
finishReason:
|
|
116
|
+
finishReason: finishReason(span),
|
|
109
117
|
error: span.status?.code === 2,
|
|
110
118
|
toolCalls,
|
|
111
119
|
prompt: this.config.recordPrompts ? jsonAttr(span, "ai.prompt.messages") : null,
|
|
@@ -165,3 +173,17 @@ export class LunarDinosSpanProcessor {
|
|
|
165
173
|
function hrtimeToMs(hrtime) {
|
|
166
174
|
return hrtime[0] * 1000 + hrtime[1] / 1_000_000;
|
|
167
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Finish reason from a root AI SDK span. v5+ sets `ai.response.finishReason`
|
|
178
|
+
* (a string); the gen_ai convention sets `gen_ai.response.finish_reasons` (a
|
|
179
|
+
* string array) — read either.
|
|
180
|
+
*/
|
|
181
|
+
function finishReason(span) {
|
|
182
|
+
const direct = strAttr(span, "ai.response.finishReason");
|
|
183
|
+
if (direct)
|
|
184
|
+
return direct;
|
|
185
|
+
const reasons = attr(span, "gen_ai.response.finish_reasons");
|
|
186
|
+
if (Array.isArray(reasons) && typeof reasons[0] === "string")
|
|
187
|
+
return reasons[0];
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"processor.test.d.ts","sourceRoot":"","sources":["../../src/processor.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { LunarDinosSpanProcessor } from "./processor";
|
|
3
|
+
/** Minimal ReadableSpan stub with just the fields the processor reads. */
|
|
4
|
+
function makeSpan(opts) {
|
|
5
|
+
return {
|
|
6
|
+
name: opts.name,
|
|
7
|
+
attributes: opts.attributes,
|
|
8
|
+
startTime: opts.startTime ?? [1000, 0],
|
|
9
|
+
endTime: opts.endTime ?? [1002, 500_000_000], // 2.5s later
|
|
10
|
+
status: { code: opts.statusCode ?? 0 },
|
|
11
|
+
instrumentationScope: { name: "ai" },
|
|
12
|
+
spanContext: () => ({ traceId: opts.traceId ?? "trace-1", spanId: "span-1" }),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe("LunarDinosSpanProcessor", () => {
|
|
16
|
+
let sent;
|
|
17
|
+
let originalFetch;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
sent = [];
|
|
20
|
+
originalFetch = globalThis.fetch;
|
|
21
|
+
globalThis.fetch = (async (_url, init) => {
|
|
22
|
+
const body = JSON.parse(init?.body ?? "{}");
|
|
23
|
+
sent.push(...body.events);
|
|
24
|
+
return new Response(null, { status: 200 });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
});
|
|
30
|
+
// A v6 root `ai.generateText` span: token usage lives under
|
|
31
|
+
// `ai.usage.inputTokens`/`outputTokens`, finish reason under
|
|
32
|
+
// `ai.response.finishReason`. (gen_ai.* + promptTokens are on child spans.)
|
|
33
|
+
function v6RootSpan() {
|
|
34
|
+
return makeSpan({
|
|
35
|
+
name: "ai.generateText",
|
|
36
|
+
attributes: {
|
|
37
|
+
"ai.model.id": "gemini-2.5-flash",
|
|
38
|
+
"ai.usage.inputTokens": 1200,
|
|
39
|
+
"ai.usage.outputTokens": 340,
|
|
40
|
+
"ai.response.finishReason": "stop",
|
|
41
|
+
"ai.response.text": "hello world",
|
|
42
|
+
"ai.prompt.messages": JSON.stringify([{ role: "user", content: "hi" }]),
|
|
43
|
+
"ai.telemetry.metadata.lunardinos.source": "chat",
|
|
44
|
+
"ai.telemetry.metadata.lunardinos.account": JSON.stringify({ id: "acme" }),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
test("extracts non-zero tokens, cost, and finish reason from a v6 root span", async () => {
|
|
49
|
+
const proc = new LunarDinosSpanProcessor({ apiKey: "ik_test", flushIntervalMs: 999_999 });
|
|
50
|
+
proc.onEnd(v6RootSpan());
|
|
51
|
+
await proc.forceFlush();
|
|
52
|
+
expect(sent).toHaveLength(1);
|
|
53
|
+
const { payload } = sent[0];
|
|
54
|
+
expect(payload.model).toBe("gemini-2.5-flash");
|
|
55
|
+
expect(payload.inputTokens).toBe(1200);
|
|
56
|
+
expect(payload.outputTokens).toBe(340);
|
|
57
|
+
// gemini-2.5-flash: 15c/1M in, 60c/1M out → 1200/1e6*15 + 340/1e6*60
|
|
58
|
+
expect(payload.costCents).toBeCloseTo((1200 / 1e6) * 15 + (340 / 1e6) * 60, 10);
|
|
59
|
+
expect(payload.costCents).toBeGreaterThan(0);
|
|
60
|
+
expect(payload.finishReason).toBe("stop");
|
|
61
|
+
expect(payload.operation).toBe("generateText");
|
|
62
|
+
expect(payload.durationMs).toBe(2500);
|
|
63
|
+
expect(payload.error).toBe(false);
|
|
64
|
+
await proc.shutdown();
|
|
65
|
+
});
|
|
66
|
+
test("falls back to gen_ai.* / promptTokens names for older SDKs", async () => {
|
|
67
|
+
const proc = new LunarDinosSpanProcessor({ apiKey: "ik_test", flushIntervalMs: 999_999 });
|
|
68
|
+
proc.onEnd(makeSpan({
|
|
69
|
+
name: "ai.generateText",
|
|
70
|
+
attributes: {
|
|
71
|
+
"ai.model.id": "gpt-4o",
|
|
72
|
+
"gen_ai.usage.input_tokens": 100,
|
|
73
|
+
"gen_ai.usage.output_tokens": 50,
|
|
74
|
+
"gen_ai.response.finish_reasons": ["length"],
|
|
75
|
+
"ai.telemetry.metadata.lunardinos.source": "chat",
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
await proc.forceFlush();
|
|
79
|
+
expect(sent).toHaveLength(1);
|
|
80
|
+
expect(sent[0].payload.inputTokens).toBe(100);
|
|
81
|
+
expect(sent[0].payload.outputTokens).toBe(50);
|
|
82
|
+
expect(sent[0].payload.finishReason).toBe("length");
|
|
83
|
+
await proc.shutdown();
|
|
84
|
+
});
|
|
85
|
+
test("does not record prompt/response unless recordPrompts is set", async () => {
|
|
86
|
+
const proc = new LunarDinosSpanProcessor({ apiKey: "ik_test", flushIntervalMs: 999_999 });
|
|
87
|
+
proc.onEnd(v6RootSpan());
|
|
88
|
+
await proc.forceFlush();
|
|
89
|
+
expect(sent[0].payload.prompt).toBeNull();
|
|
90
|
+
expect(sent[0].payload.response).toBeNull();
|
|
91
|
+
await proc.shutdown();
|
|
92
|
+
sent = [];
|
|
93
|
+
const proc2 = new LunarDinosSpanProcessor({
|
|
94
|
+
apiKey: "ik_test",
|
|
95
|
+
recordPrompts: true,
|
|
96
|
+
flushIntervalMs: 999_999,
|
|
97
|
+
});
|
|
98
|
+
proc2.onEnd(v6RootSpan());
|
|
99
|
+
await proc2.forceFlush();
|
|
100
|
+
expect(sent[0].payload.response).toBe("hello world");
|
|
101
|
+
expect(sent[0].payload.prompt).toEqual([{ role: "user", content: "hi" }]);
|
|
102
|
+
await proc2.shutdown();
|
|
103
|
+
});
|
|
104
|
+
test("drops events with no attribution context", async () => {
|
|
105
|
+
const proc = new LunarDinosSpanProcessor({ apiKey: "ik_test", flushIntervalMs: 999_999 });
|
|
106
|
+
proc.onEnd(makeSpan({
|
|
107
|
+
name: "ai.generateText",
|
|
108
|
+
attributes: { "ai.model.id": "gpt-4o", "ai.usage.inputTokens": 10 },
|
|
109
|
+
}));
|
|
110
|
+
await proc.forceFlush();
|
|
111
|
+
expect(sent).toHaveLength(0);
|
|
112
|
+
await proc.shutdown();
|
|
113
|
+
});
|
|
114
|
+
test("ignores non-AI spans", async () => {
|
|
115
|
+
const proc = new LunarDinosSpanProcessor({ apiKey: "ik_test", flushIntervalMs: 999_999 });
|
|
116
|
+
const span = makeSpan({
|
|
117
|
+
name: "ai.generateText",
|
|
118
|
+
attributes: { "ai.usage.inputTokens": 10 },
|
|
119
|
+
});
|
|
120
|
+
span.instrumentationScope = {
|
|
121
|
+
name: "http",
|
|
122
|
+
};
|
|
123
|
+
proc.onEnd(span);
|
|
124
|
+
await proc.forceFlush();
|
|
125
|
+
expect(sent).toHaveLength(0);
|
|
126
|
+
await proc.shutdown();
|
|
127
|
+
});
|
|
128
|
+
test("attaches captured tool calls to the root generation", async () => {
|
|
129
|
+
const proc = new LunarDinosSpanProcessor({
|
|
130
|
+
apiKey: "ik_test",
|
|
131
|
+
recordPrompts: true,
|
|
132
|
+
flushIntervalMs: 999_999,
|
|
133
|
+
});
|
|
134
|
+
proc.onEnd(makeSpan({
|
|
135
|
+
name: "ai.toolCall",
|
|
136
|
+
traceId: "trace-tc",
|
|
137
|
+
attributes: {
|
|
138
|
+
"ai.toolCall.name": "search",
|
|
139
|
+
"ai.toolCall.args": JSON.stringify({ q: "dinos" }),
|
|
140
|
+
"ai.toolCall.result": JSON.stringify({ hits: 3 }),
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
proc.onEnd(makeSpan({
|
|
144
|
+
name: "ai.generateText",
|
|
145
|
+
traceId: "trace-tc",
|
|
146
|
+
attributes: {
|
|
147
|
+
"ai.model.id": "gpt-4o",
|
|
148
|
+
"ai.usage.inputTokens": 10,
|
|
149
|
+
"ai.usage.outputTokens": 5,
|
|
150
|
+
"ai.telemetry.metadata.lunardinos.source": "chat",
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
await proc.forceFlush();
|
|
154
|
+
expect(sent).toHaveLength(1);
|
|
155
|
+
const tc = sent[0].payload;
|
|
156
|
+
expect(tc.toolCalls).toHaveLength(1);
|
|
157
|
+
expect(tc.toolCalls[0].name).toBe("search");
|
|
158
|
+
expect(tc.toolCalls[0].args).toEqual({ q: "dinos" });
|
|
159
|
+
await proc.shutdown();
|
|
160
|
+
});
|
|
161
|
+
});
|