@lunardinos/server 0.2.2 → 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
@@ -88,7 +88,7 @@ class LunarDinosSpanProcessor {
88
88
  }
89
89
  onStart(_span, _context) {}
90
90
  onEnd(span) {
91
- const scope = span.instrumentationLibrary?.name;
91
+ const scope = span.instrumentationScope?.name ?? span.instrumentationLibrary?.name;
92
92
  if (scope !== "ai")
93
93
  return;
94
94
  if (span.name.startsWith("ai.toolCall")) {
@@ -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;IAgFzB,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"}
@@ -65,7 +65,11 @@ export class LunarDinosSpanProcessor {
65
65
  }
66
66
  onStart(_span, _context) { }
67
67
  onEnd(span) {
68
- const scope = span.instrumentationLibrary?.name;
68
+ // OTel 2.x renamed `instrumentationLibrary` to `instrumentationScope`. The
69
+ // new field isn't on the 1.x ReadableSpan type we build against, so read it
70
+ // through a widened view and fall back to the legacy field for 1.x spans.
71
+ const scope = span.instrumentationScope?.name ??
72
+ span.instrumentationLibrary?.name;
69
73
  if (scope !== "ai")
70
74
  return;
71
75
  if (span.name.startsWith("ai.toolCall")) {
@@ -79,8 +83,16 @@ export class LunarDinosSpanProcessor {
79
83
  return;
80
84
  const traceId = span.spanContext().traceId;
81
85
  const model = strAttr(span, "ai.model.id") ?? strAttr(span, "gen_ai.request.model");
82
- const inputTokens = numAttr(span, "gen_ai.usage.input_tokens") || numAttr(span, "ai.usage.promptTokens");
83
- 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");
84
96
  const startMs = hrtimeToMs(span.startTime);
85
97
  const endMs = hrtimeToMs(span.endTime);
86
98
  const childToolCalls = this.toolCallSpans.get(traceId);
@@ -101,7 +113,7 @@ export class LunarDinosSpanProcessor {
101
113
  durationMs: Math.round(endMs - startMs),
102
114
  msToFirstChunk: numAttr(span, "ai.response.msToFirstChunk") || null,
103
115
  operation: span.name.replace("ai.", ""),
104
- finishReason: strAttr(span, "ai.finishReason") ?? strAttr(span, "gen_ai.response.finish_reasons"),
116
+ finishReason: finishReason(span),
105
117
  error: span.status?.code === 2,
106
118
  toolCalls,
107
119
  prompt: this.config.recordPrompts ? jsonAttr(span, "ai.prompt.messages") : null,
@@ -161,3 +173,17 @@ export class LunarDinosSpanProcessor {
161
173
  function hrtimeToMs(hrtime) {
162
174
  return hrtime[0] * 1000 + hrtime[1] / 1_000_000;
163
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.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -19,7 +19,13 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@opentelemetry/api": "^1.9",
22
+ "@opentelemetry/api": "^1.9"
23
+ },
24
+ "peerDependencies": {
25
+ "@opentelemetry/sdk-trace-base": ">=1.30 <3",
26
+ "@opentelemetry/sdk-trace-node": ">=1.30 <3"
27
+ },
28
+ "devDependencies": {
23
29
  "@opentelemetry/sdk-trace-base": "^1.30",
24
30
  "@opentelemetry/sdk-trace-node": "^1.30"
25
31
  }