@lunardinos/server 0.2.3 → 0.2.4

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: strAttr(span, "ai.finishReason") ?? strAttr(span, "gen_ai.response.finish_reasons"),
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;IAqFzB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;YAQjB,KAAK;CAepB"}
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"}
@@ -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
- const inputTokens = numAttr(span, "gen_ai.usage.input_tokens") || numAttr(span, "ai.usage.promptTokens");
87
- const outputTokens = numAttr(span, "gen_ai.usage.output_tokens") || numAttr(span, "ai.usage.completionTokens");
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: strAttr(span, "ai.finishReason") ?? strAttr(span, "gen_ai.response.finish_reasons"),
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=processor.test.d.ts.map
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunardinos/server",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {