@neutrome/open-ai-router 0.5.4 → 0.5.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/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
7
7
  },
8
8
  "dependencies": {
9
+ "@posthog/ai": "^8.3.0",
9
10
  "@modelcontextprotocol/sdk": "^1.29.0",
10
11
  "hono": "^4.12.18",
12
+ "posthog-node": "^5.41.0",
13
+ "zod": "^4.2.1",
11
14
  "@neutrome/lil-engine": "0.3.1",
12
15
  "@neutrome/lilsdk-ts": "0.3.1"
13
16
  },
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import type { ExecutionSummary } from "../telemetry.ts";
2
+ import type { ExecutionSummary } from "../telemetry/index.ts";
3
3
  import type { RouterConfig } from "../router/index.ts";
4
4
  import { createRouterApp } from "./factory.ts";
5
5
 
@@ -7,7 +7,7 @@ import {
7
7
  } from "@neutrome/lil-engine";
8
8
  import { cors } from "./cors.ts";
9
9
  import { healthHandler } from "./health.ts";
10
- import { createRouterTelemetry } from "../telemetry.ts";
10
+ import { createRouterTelemetry } from "../telemetry/index.ts";
11
11
  import { canAccessPrincipalModel } from "../admission/access.ts";
12
12
  import {
13
13
  createRouterRuntime,
@@ -91,7 +91,12 @@ export function createRouterApp(options: RouterAppOptions) {
91
91
  ): Promise<Response> {
92
92
  const env = honoEnv(c);
93
93
  const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
94
- const telemetry = createRouterTelemetry(env);
94
+ const telemetry = createRouterTelemetry({
95
+ env,
96
+ ...(options.telemetryDistinctId
97
+ ? { distinctId: options.telemetryDistinctId }
98
+ : {}),
99
+ });
95
100
  const onExecutionSummary = options.hooks?.onExecutionSummary;
96
101
  const observe = (
97
102
  event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0],
package/src/app/types.ts CHANGED
@@ -3,7 +3,7 @@ import type {
3
3
  AuthPrincipal,
4
4
  RuntimeAuthenticator,
5
5
  } from "../admission/types.ts";
6
- import type { ExecutionSummary } from "../telemetry.ts";
6
+ import type { ExecutionSummary } from "../telemetry/index.ts";
7
7
  import type { RouterConfig } from "../router/config.ts";
8
8
  import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
9
9
  import type { RemoteMcpClientFactory } from "../router/mcp.ts";
@@ -34,5 +34,6 @@ export type RouterAppOptions = {
34
34
  requestIdFactory?: ExecutionRuntimeOptions["requestIdFactory"];
35
35
  executionIdFactory?: ExecutionRuntimeOptions["executionIdFactory"];
36
36
  upstreamTimeoutMs?: number;
37
+ telemetryDistinctId?: string;
37
38
  remoteMcpClientFactory?: RemoteMcpClientFactory;
38
39
  };
package/src/index.ts CHANGED
@@ -4,12 +4,18 @@ export type {
4
4
  RouterAppHooks,
5
5
  RouterAppOptions,
6
6
  } from "./app/types.ts";
7
- export { createRouterTelemetry } from "./telemetry.ts";
7
+ export {
8
+ createRouterTelemetry,
9
+ PosthogErrorTelemetry,
10
+ PosthogTraceTelemetry,
11
+ } from "./telemetry/index.ts";
8
12
  export type {
13
+ ErrorTelemetryDriver,
9
14
  ExecutionSummary,
10
15
  ExecutionSummarySpan,
11
16
  RouterTelemetry,
12
- } from "./telemetry.ts";
17
+ TraceTelemetryDriver,
18
+ } from "./telemetry/index.ts";
13
19
 
14
20
  export {
15
21
  createFetchProviderInvoker,
@@ -112,6 +112,8 @@ export function createFetchProviderInvoker(
112
112
  provider.name,
113
113
  reqBytes,
114
114
  bytes.byteLength,
115
+ jsonBody(reqBody),
116
+ jsonBody(bytes),
115
117
  );
116
118
  return response;
117
119
  } catch (error) {
@@ -132,3 +134,11 @@ export function createFetchProviderInvoker(
132
134
  },
133
135
  };
134
136
  }
137
+
138
+ function jsonBody(body: Uint8Array): unknown {
139
+ try {
140
+ return JSON.parse(new TextDecoder().decode(body));
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
@@ -102,6 +102,7 @@ export async function* streamProvider(
102
102
  let firstByteObserved = false;
103
103
  const streamReadStartedMs = Date.now();
104
104
  let lastChunkWithUsage: Program | null = null;
105
+ const responseChunks: unknown[] = [];
105
106
  let respBytes = 0;
106
107
  while (true) {
107
108
  const { done, value } = await reader.read();
@@ -140,10 +141,13 @@ export async function* streamProvider(
140
141
  provider.name,
141
142
  reqBytes,
142
143
  respBytes,
144
+ jsonBody(reqBody),
145
+ responseChunks,
143
146
  );
144
147
  return;
145
148
  }
146
149
  if (data[0] !== "{") continue;
150
+ responseChunks.push(jsonBody(encoder.encode(data)));
147
151
  const chunk = parseProviderStreamChunk(
148
152
  provider.style,
149
153
  encoder.encode(data),
@@ -163,10 +167,13 @@ export async function* streamProvider(
163
167
  provider.name,
164
168
  reqBytes,
165
169
  respBytes,
170
+ jsonBody(reqBody),
171
+ responseChunks,
166
172
  );
167
173
  return;
168
174
  }
169
175
  if (data[0] !== "{") continue;
176
+ responseChunks.push(jsonBody(encoder.encode(data)));
170
177
  const chunk = parseProviderStreamChunk(
171
178
  provider.style,
172
179
  encoder.encode(data),
@@ -181,6 +188,8 @@ export async function* streamProvider(
181
188
  provider.name,
182
189
  reqBytes,
183
190
  respBytes,
191
+ jsonBody(reqBody),
192
+ responseChunks,
184
193
  );
185
194
  } catch (error) {
186
195
  throw normalizeUpstreamAbort(error, timed.signal);
@@ -190,3 +199,11 @@ export async function* streamProvider(
190
199
  timed.cleanup();
191
200
  }
192
201
  }
202
+
203
+ function jsonBody(body: Uint8Array): unknown {
204
+ try {
205
+ return JSON.parse(new TextDecoder().decode(body));
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
@@ -1,9 +1,11 @@
1
1
  import { usageObject, type Program } from "@neutrome/lil-engine";
2
2
  import { createExecutionEvent } from "@neutrome/lilsdk-ts";
3
+ import { z } from "zod";
3
4
  import type { ProviderInvocationContext } from "./execution-types.ts";
4
5
 
5
6
  const encoder = new TextEncoder();
6
7
  const decoder = new TextDecoder();
8
+ const TokenCountSchema = z.number().finite().nonnegative();
7
9
 
8
10
  export function emitProviderUsage(
9
11
  providerCtx: ProviderInvocationContext,
@@ -11,6 +13,8 @@ export function emitProviderUsage(
11
13
  provider: string,
12
14
  requestBytes: number,
13
15
  responseBytes: number,
16
+ input?: unknown,
17
+ output?: unknown,
14
18
  ): void {
15
19
  const usage = response ? usageObject(response) : undefined;
16
20
  const u = (usage && typeof usage === "object" ? usage : {}) as Record<
@@ -39,13 +43,15 @@ export function emitProviderUsage(
39
43
  cachedTokensInput,
40
44
  requestBytes,
41
45
  responseBytes,
46
+ ...(input === undefined ? {} : { input }),
47
+ ...(output === undefined ? {} : { output }),
42
48
  },
43
49
  }),
44
50
  );
45
51
  }
46
52
 
47
53
  function asNum(v: unknown): number {
48
- return typeof v === "number" && v >= 0 ? v : 0;
54
+ return TokenCountSchema.safeParse(v).data ?? 0;
49
55
  }
50
56
 
51
57
  export function injectStreamUsage(body: Uint8Array): Uint8Array {
@@ -0,0 +1,13 @@
1
+ export { createRouterTelemetry } from "./router.ts";
2
+ export { PosthogErrorTelemetry, PosthogTraceTelemetry } from "./posthog.ts";
3
+ export type {
4
+ ErrorTelemetryDriver,
5
+ TelemetryError,
6
+ TraceGeneration,
7
+ TraceTelemetryDriver,
8
+ } from "./types.ts";
9
+ export type {
10
+ ExecutionSummary,
11
+ ExecutionSummarySpan,
12
+ RouterTelemetry,
13
+ } from "./router.ts";
@@ -0,0 +1,108 @@
1
+ import { captureAiGeneration } from "@posthog/ai";
2
+ import { PostHog } from "posthog-node/edge";
3
+ import { z } from "zod";
4
+ import type {
5
+ ErrorTelemetryDriver,
6
+ TelemetryError,
7
+ TraceGeneration,
8
+ TraceTelemetryDriver,
9
+ } from "./types.ts";
10
+
11
+ type PosthogClient = Pick<
12
+ PostHog,
13
+ "capture" | "captureImmediate" | "captureExceptionImmediate"
14
+ >;
15
+
16
+ const PosthogConfigSchema = z.object({
17
+ apiKey: z.string().min(1).optional(),
18
+ host: z.url().optional(),
19
+ distinctId: z.string().min(1).optional(),
20
+ });
21
+
22
+ export class PosthogTraceTelemetry implements TraceTelemetryDriver {
23
+ constructor(
24
+ private readonly client: PostHog,
25
+ private readonly distinctId: string,
26
+ ) {}
27
+
28
+ async recordGeneration(generation: TraceGeneration): Promise<void> {
29
+ await captureAiGeneration(this.client as never, {
30
+ distinctId: this.distinctId,
31
+ traceId: generation.traceId,
32
+ provider: generation.provider,
33
+ ...(generation.model ? { model: generation.model } : {}),
34
+ input: generation.input ?? null,
35
+ output: generation.output ?? null,
36
+ latency: elapsedSeconds(generation.startedAt, generation.finishedAt),
37
+ ...(generation.timeToFirstTokenMs === undefined
38
+ ? {}
39
+ : { timeToFirstToken: generation.timeToFirstTokenMs / 1_000 }),
40
+ usage: {
41
+ inputTokens: generation.tokensInput,
42
+ outputTokens: generation.tokensOutput,
43
+ },
44
+ properties: compact({
45
+ "lil.request_id": generation.requestId,
46
+ "lil.execution_id": generation.executionId,
47
+ "lil.parent_execution_id": generation.parentExecutionId,
48
+ "lil.cached_input_tokens": generation.cachedTokensInput || undefined,
49
+ "lil.request_bytes": generation.requestBytes || undefined,
50
+ "lil.response_bytes": generation.responseBytes || undefined,
51
+ }),
52
+ captureImmediate: true,
53
+ });
54
+ }
55
+ }
56
+
57
+ export class PosthogErrorTelemetry implements ErrorTelemetryDriver {
58
+ constructor(
59
+ private readonly client: PosthogClient,
60
+ private readonly distinctId: string,
61
+ ) {}
62
+
63
+ async recordError(error: TelemetryError): Promise<void> {
64
+ await this.client.captureExceptionImmediate(
65
+ new Error(error.message),
66
+ this.distinctId,
67
+ {
68
+ "lil.trace_id": error.traceId,
69
+ "lil.request_id": error.requestId,
70
+ "lil.execution_id": error.executionId,
71
+ },
72
+ );
73
+ }
74
+ }
75
+
76
+ export function createPosthogTelemetryDrivers(input: {
77
+ apiKey?: unknown;
78
+ host?: unknown;
79
+ distinctId?: string;
80
+ }): {
81
+ traceDriver?: TraceTelemetryDriver;
82
+ errorDriver?: ErrorTelemetryDriver;
83
+ } {
84
+ const config = PosthogConfigSchema.safeParse(input);
85
+ if (!config.success || !config.data.apiKey) return {};
86
+ const { apiKey, host, distinctId } = config.data;
87
+
88
+ const client = new PostHog(apiKey, {
89
+ host: host ?? "https://us.i.posthog.com",
90
+ flushAt: 1,
91
+ flushInterval: 0,
92
+ });
93
+ const resolvedDistinctId = distinctId ?? "anonymous";
94
+ return {
95
+ traceDriver: new PosthogTraceTelemetry(client, resolvedDistinctId),
96
+ errorDriver: new PosthogErrorTelemetry(client, resolvedDistinctId),
97
+ };
98
+ }
99
+
100
+ function elapsedSeconds(startedAt: string, finishedAt: string): number {
101
+ return Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)) / 1_000;
102
+ }
103
+
104
+ function compact(values: Record<string, string | number | undefined>) {
105
+ return Object.fromEntries(
106
+ Object.entries(values).filter(([, value]) => value !== undefined),
107
+ );
108
+ }
@@ -0,0 +1,291 @@
1
+ import type { ExecutionEvent } from "@neutrome/lilsdk-ts";
2
+ import { z } from "zod";
3
+ import { createPosthogTelemetryDrivers } from "./posthog.ts";
4
+ import type {
5
+ ErrorTelemetryDriver,
6
+ TelemetryError,
7
+ TraceGeneration,
8
+ TraceTelemetryDriver,
9
+ } from "./types.ts";
10
+
11
+ export type ExecutionSummary = {
12
+ spanId: string;
13
+ startedAt: string;
14
+ finishedAt: string;
15
+ spans: ExecutionSummarySpan[];
16
+ totalTokensInput: number;
17
+ totalTokensOutput: number;
18
+ totalCachedTokensInput: number;
19
+ error?: string;
20
+ };
21
+
22
+ export type ExecutionSummarySpan = {
23
+ kind: "request" | "provider" | "tool" | "executor";
24
+ event: string;
25
+ requestId: string;
26
+ executionId: string;
27
+ parentExecutionId?: string;
28
+ model?: string;
29
+ provider?: string;
30
+ toolName?: string;
31
+ executor?: string;
32
+ startedAt: string;
33
+ finishedAt?: string;
34
+ tokensInput: number;
35
+ tokensOutput: number;
36
+ cachedTokensInput: number;
37
+ requestBytes: number;
38
+ responseBytes: number;
39
+ error?: string;
40
+ };
41
+
42
+ export type RouterTelemetry = {
43
+ observe(event: ExecutionEvent): void;
44
+ recordError(input: Omit<TelemetryError, "traceId">): void;
45
+ flush(): Promise<void>;
46
+ summary(): ExecutionSummary;
47
+ };
48
+
49
+ type ActiveSpan = {
50
+ summary: ExecutionSummarySpan;
51
+ input?: unknown;
52
+ output?: unknown;
53
+ timeToFirstTokenMs?: number;
54
+ };
55
+
56
+ const EventDataSchema = z
57
+ .object({
58
+ startedAt: z.string().min(1).optional(),
59
+ finishedAt: z.string().min(1).optional(),
60
+ message: z.string().min(1).optional(),
61
+ model: z.string().min(1).optional(),
62
+ provider: z.string().min(1).optional(),
63
+ toolName: z.string().min(1).optional(),
64
+ executor: z.string().min(1).optional(),
65
+ executorId: z.string().min(1).optional(),
66
+ tokensInput: z.number().finite().nonnegative().optional(),
67
+ tokensOutput: z.number().finite().nonnegative().optional(),
68
+ cachedTokensInput: z.number().finite().nonnegative().optional(),
69
+ requestBytes: z.number().finite().nonnegative().optional(),
70
+ responseBytes: z.number().finite().nonnegative().optional(),
71
+ })
72
+ .passthrough();
73
+
74
+ export function createRouterTelemetry(input: {
75
+ env?: Record<string, unknown>;
76
+ distinctId?: string;
77
+ traceDriver?: TraceTelemetryDriver;
78
+ errorDriver?: ErrorTelemetryDriver;
79
+ } = {}): RouterTelemetry {
80
+ const drivers = createPosthogTelemetryDrivers({
81
+ apiKey: input.env?.POSTHOG_API_KEY,
82
+ host: input.env?.POSTHOG_HOST,
83
+ ...(input.distinctId ? { distinctId: input.distinctId } : {}),
84
+ });
85
+ const traceDriver = input.traceDriver ?? drivers.traceDriver;
86
+ const errorDriver = input.errorDriver ?? drivers.errorDriver;
87
+ const traceId = crypto.randomUUID().replaceAll("-", "");
88
+ const startedAt = new Date().toISOString();
89
+ const spans: ExecutionSummarySpan[] = [];
90
+ const activeSpans = new Map<string, ActiveSpan>();
91
+ const providerGenerations: ActiveSpan[] = [];
92
+ const errors = new Map<string, TelemetryError>();
93
+ let error: string | undefined;
94
+
95
+ return { observe, recordError, flush, summary };
96
+
97
+ function observe(event: ExecutionEvent): void {
98
+ const timing = eventTiming(event);
99
+ if (isInvocationStarted(event.kind)) {
100
+ const active = { summary: createSpan(event, timing.startedAt ?? event.timestamp) };
101
+ spans.push(active.summary);
102
+ activeSpans.set(event.executionId, active);
103
+ return;
104
+ }
105
+ if (isInvocationFinished(event.kind)) {
106
+ const active = activeSpans.get(event.executionId);
107
+ if (active) {
108
+ active.summary.finishedAt = timing.finishedAt ?? event.timestamp;
109
+ activeSpans.delete(event.executionId);
110
+ if (active.summary.kind === "provider") providerGenerations.push(active);
111
+ }
112
+ return;
113
+ }
114
+ if (event.kind === "provider.usage") {
115
+ const active = activeSpans.get(event.executionId);
116
+ if (active) applyUsage(active, event);
117
+ return;
118
+ }
119
+ if (event.kind === "provider.stream_first_byte") {
120
+ const active = activeSpans.get(event.executionId);
121
+ if (active) {
122
+ active.timeToFirstTokenMs = Math.max(
123
+ 0,
124
+ Date.parse(timing.finishedAt ?? event.timestamp) -
125
+ Date.parse(active.summary.startedAt),
126
+ );
127
+ }
128
+ const span = createSpan(event, timing.startedAt ?? event.timestamp);
129
+ span.finishedAt = timing.finishedAt ?? event.timestamp;
130
+ spans.push(span);
131
+ return;
132
+ }
133
+ if (event.kind === "execution.failed") {
134
+ const message = eventData(event).message ?? "Execution failed";
135
+ error ??= message;
136
+ const active = activeSpans.get(event.executionId);
137
+ if (active) {
138
+ active.summary.error = message;
139
+ active.summary.finishedAt ??= event.timestamp;
140
+ }
141
+ recordError({ message, requestId: event.requestId, executionId: event.executionId });
142
+ return;
143
+ }
144
+ if (!timing.startedAt) return;
145
+ const span = createSpan(event, timing.startedAt);
146
+ span.finishedAt = timing.finishedAt ?? event.timestamp;
147
+ spans.push(span);
148
+ }
149
+
150
+ function recordError(input: Omit<TelemetryError, "traceId">): void {
151
+ error ??= input.message;
152
+ const telemetryError = { ...input, traceId };
153
+ errors.set(
154
+ `${telemetryError.requestId}:${telemetryError.executionId}:${telemetryError.message}`,
155
+ telemetryError,
156
+ );
157
+ }
158
+
159
+ async function flush(): Promise<void> {
160
+ const finishedAt = new Date().toISOString();
161
+ for (const active of activeSpans.values()) {
162
+ active.summary.finishedAt ??= finishedAt;
163
+ }
164
+ const generations = [
165
+ ...providerGenerations,
166
+ ...[...activeSpans.values()].filter(({ summary }) => summary.kind === "provider"),
167
+ ].map(toGeneration);
168
+ activeSpans.clear();
169
+ await reportAll([
170
+ ...generations.map((generation) => traceDriver?.recordGeneration(generation)),
171
+ ...[...errors.values()].map((telemetryError) =>
172
+ errorDriver?.recordError(telemetryError),
173
+ ),
174
+ ]);
175
+ }
176
+
177
+ function summary(): ExecutionSummary {
178
+ const finishedAt = new Date().toISOString();
179
+ return {
180
+ spanId: traceId,
181
+ startedAt,
182
+ finishedAt,
183
+ spans,
184
+ totalTokensInput: spans.reduce((total, span) => total + span.tokensInput, 0),
185
+ totalTokensOutput: spans.reduce((total, span) => total + span.tokensOutput, 0),
186
+ totalCachedTokensInput: spans.reduce(
187
+ (total, span) => total + span.cachedTokensInput,
188
+ 0,
189
+ ),
190
+ ...(error ? { error } : {}),
191
+ };
192
+ }
193
+
194
+ function toGeneration(active: ActiveSpan): TraceGeneration {
195
+ const { summary } = active;
196
+ return {
197
+ traceId,
198
+ requestId: summary.requestId,
199
+ executionId: summary.executionId,
200
+ ...(summary.parentExecutionId
201
+ ? { parentExecutionId: summary.parentExecutionId }
202
+ : {}),
203
+ provider: summary.provider ?? "unknown",
204
+ ...(summary.model ? { model: summary.model } : {}),
205
+ startedAt: summary.startedAt,
206
+ finishedAt: summary.finishedAt ?? new Date().toISOString(),
207
+ tokensInput: summary.tokensInput,
208
+ tokensOutput: summary.tokensOutput,
209
+ cachedTokensInput: summary.cachedTokensInput,
210
+ requestBytes: summary.requestBytes,
211
+ responseBytes: summary.responseBytes,
212
+ ...(active.input === undefined ? {} : { input: active.input }),
213
+ ...(active.output === undefined ? {} : { output: active.output }),
214
+ ...(active.timeToFirstTokenMs === undefined
215
+ ? {}
216
+ : { timeToFirstTokenMs: active.timeToFirstTokenMs }),
217
+ };
218
+ }
219
+ }
220
+
221
+ function createSpan(event: ExecutionEvent, startedAt: string): ExecutionSummarySpan {
222
+ return {
223
+ kind: spanKind(event.kind),
224
+ event: event.kind,
225
+ requestId: event.requestId,
226
+ executionId: event.executionId,
227
+ ...(event.parentExecutionId ? { parentExecutionId: event.parentExecutionId } : {}),
228
+ ...eventDetails(event),
229
+ startedAt,
230
+ tokensInput: 0,
231
+ tokensOutput: 0,
232
+ cachedTokensInput: 0,
233
+ requestBytes: 0,
234
+ responseBytes: 0,
235
+ };
236
+ }
237
+
238
+ function applyUsage(active: ActiveSpan, event: ExecutionEvent): void {
239
+ const { summary } = active;
240
+ const data = eventData(event);
241
+ summary.tokensInput = data.tokensInput ?? 0;
242
+ summary.tokensOutput = data.tokensOutput ?? 0;
243
+ summary.cachedTokensInput = data.cachedTokensInput ?? 0;
244
+ summary.requestBytes = data.requestBytes ?? 0;
245
+ summary.responseBytes = data.responseBytes ?? 0;
246
+ active.input = event.data?.input;
247
+ active.output = event.data?.output;
248
+ }
249
+
250
+ function eventTiming(event: ExecutionEvent) {
251
+ return {
252
+ startedAt: eventData(event).startedAt,
253
+ finishedAt: eventData(event).finishedAt,
254
+ };
255
+ }
256
+
257
+ function isInvocationStarted(kind: string): boolean {
258
+ return kind.endsWith(".started") || kind.endsWith(".stream_started");
259
+ }
260
+ function isInvocationFinished(kind: string): boolean {
261
+ return kind.endsWith(".finished") || kind.endsWith(".stream_finished");
262
+ }
263
+ function spanKind(kind: string): ExecutionSummarySpan["kind"] {
264
+ if (kind.startsWith("provider.")) return "provider";
265
+ if (kind.startsWith("tool.")) return "tool";
266
+ if (kind.startsWith("executor.")) return "executor";
267
+ return "request";
268
+ }
269
+ function eventDetails(event: ExecutionEvent): Partial<ExecutionSummarySpan> {
270
+ return {
271
+ ...optional("model", eventData(event).model),
272
+ ...optional("provider", eventData(event).provider),
273
+ ...optional("toolName", eventData(event).toolName),
274
+ ...optional("executor", eventData(event).executor ?? eventData(event).executorId),
275
+ };
276
+ }
277
+ async function reportAll(reports: Array<Promise<void> | undefined>): Promise<void> {
278
+ const results = await Promise.allSettled(reports.filter(Boolean));
279
+ for (const result of results) {
280
+ if (result.status === "rejected") {
281
+ console.error("[telemetry] PostHog capture failed", result.reason);
282
+ }
283
+ }
284
+ }
285
+ function eventData(event: ExecutionEvent): z.infer<typeof EventDataSchema> {
286
+ const parsed = EventDataSchema.safeParse(event.data ?? {});
287
+ return parsed.success ? parsed.data : {};
288
+ }
289
+ function optional<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
290
+ return value ? ({ [key]: value } as Record<K, string>) : {};
291
+ }
@@ -0,0 +1,33 @@
1
+ export type TraceGeneration = {
2
+ traceId: string;
3
+ requestId: string;
4
+ executionId: string;
5
+ parentExecutionId?: string;
6
+ provider: string;
7
+ model?: string;
8
+ startedAt: string;
9
+ finishedAt: string;
10
+ tokensInput: number;
11
+ tokensOutput: number;
12
+ cachedTokensInput: number;
13
+ requestBytes: number;
14
+ responseBytes: number;
15
+ input?: unknown;
16
+ output?: unknown;
17
+ timeToFirstTokenMs?: number;
18
+ };
19
+
20
+ export type TelemetryError = {
21
+ traceId: string;
22
+ requestId: string;
23
+ executionId: string;
24
+ message: string;
25
+ };
26
+
27
+ export interface TraceTelemetryDriver {
28
+ recordGeneration(generation: TraceGeneration): Promise<void>;
29
+ }
30
+
31
+ export interface ErrorTelemetryDriver {
32
+ recordError(error: TelemetryError): Promise<void>;
33
+ }
@@ -1,95 +1,125 @@
1
- import { describe, expect, it, vi } from "vitest";
2
1
  import { createExecutionEvent } from "@neutrome/lilsdk-ts";
3
- import { createRouterTelemetry } from "./telemetry.ts";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ createRouterTelemetry,
5
+ PosthogErrorTelemetry,
6
+ PosthogTraceTelemetry,
7
+ } from "./telemetry/index.ts";
4
8
 
5
9
  describe("createRouterTelemetry", () => {
6
- it("exports OTLP spans and errors to their separate endpoints", async () => {
7
- const fetchMock = vi
8
- .spyOn(globalThis, "fetch")
9
- .mockResolvedValue(new Response(null, { status: 200 }));
10
- const telemetry = createRouterTelemetry({
11
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://traces.example/v1/traces",
12
- OTEL_EXPORTER_OTLP_TRACES_HEADERS: "authorization=Bearer%20trace-token",
13
- OTEL_EXPORTER_OTLP_ERRORS_ENDPOINT: "https://errors.example/v1/traces",
14
- OTEL_EXPORTER_OTLP_ERRORS_HEADERS: "authorization=Bearer%20error-token",
15
- });
10
+ it("reports provider generations and exceptions through separate drivers", async () => {
11
+ const traceDriver = { recordGeneration: vi.fn(async () => {}) };
12
+ const errorDriver = { recordError: vi.fn(async () => {}) };
13
+ const telemetry = createRouterTelemetry({ traceDriver, errorDriver });
14
+
16
15
  telemetry.observe(
17
16
  createExecutionEvent({
18
17
  kind: "provider.started",
19
18
  requestId: "request-1",
20
19
  executionId: "provider-1",
21
- data: { startedAt: "2026-07-13T12:00:00.000Z", provider: "openai" },
20
+ data: {
21
+ startedAt: "2026-07-13T12:00:00.000Z",
22
+ provider: "openrouter",
23
+ model: "gpt-5-chat",
24
+ },
22
25
  }),
23
26
  );
24
27
  telemetry.observe(
25
28
  createExecutionEvent({
26
- kind: "execution.failed",
29
+ kind: "provider.usage",
27
30
  requestId: "request-1",
28
31
  executionId: "provider-1",
29
- data: { message: "upstream unavailable" },
32
+ data: {
33
+ tokensInput: 12,
34
+ tokensOutput: 8,
35
+ input: { messages: [{ role: "user", content: "hello" }] },
36
+ output: { choices: [{ message: { content: "hi" } }] },
37
+ },
30
38
  }),
31
39
  );
40
+ telemetry.observe(
41
+ createExecutionEvent({
42
+ kind: "provider.finished",
43
+ requestId: "request-1",
44
+ executionId: "provider-1",
45
+ data: { finishedAt: "2026-07-13T12:00:01.000Z" },
46
+ }),
47
+ );
48
+ telemetry.recordError({
49
+ message: "tool unavailable",
50
+ requestId: "request-1",
51
+ executionId: "tool-1",
52
+ });
32
53
 
33
54
  await telemetry.flush();
34
55
 
35
- expect(fetchMock).toHaveBeenCalledTimes(2);
36
- expect(fetchMock.mock.calls[0]?.[0]).toBe(
37
- "https://traces.example/v1/traces",
56
+ expect(traceDriver.recordGeneration).toHaveBeenCalledWith(
57
+ expect.objectContaining({
58
+ provider: "openrouter",
59
+ model: "gpt-5-chat",
60
+ tokensInput: 12,
61
+ tokensOutput: 8,
62
+ input: { messages: [{ role: "user", content: "hello" }] },
63
+ }),
38
64
  );
39
- expect(fetchMock.mock.calls[1]?.[0]).toBe(
40
- "https://errors.example/v1/traces",
65
+ expect(errorDriver.recordError).toHaveBeenCalledWith(
66
+ expect.objectContaining({ message: "tool unavailable" }),
41
67
  );
42
- expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
43
- authorization: "Bearer trace-token",
44
- });
45
- expect(fetchMock.mock.calls[1]?.[1]?.headers).toMatchObject({
46
- authorization: "Bearer error-token",
47
- });
48
- expect(
49
- JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)),
50
- ).toMatchObject({
51
- resourceLogs: [
52
- {
53
- scopeLogs: [
54
- {
55
- logRecords: [
56
- {
57
- severityText: "ERROR",
58
- body: { stringValue: "upstream unavailable" },
59
- },
60
- ],
61
- },
62
- ],
63
- },
64
- ],
65
- });
66
- fetchMock.mockRestore();
67
68
  });
69
+ });
68
70
 
69
- it("logs a rejected exporter response without exposing credentials", async () => {
70
- const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
71
- new Response("invalid authentication", {
72
- status: 401,
73
- statusText: "Unauthorized",
74
- }),
75
- );
76
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
77
- const telemetry = createRouterTelemetry({
78
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://traces.example/v1/traces",
79
- OTEL_EXPORTER_OTLP_TRACES_HEADERS: "authorization=Bearer%20trace-token",
71
+ describe("PostHog telemetry drivers", () => {
72
+ it("uses the native AI event and exception APIs independently", async () => {
73
+ const captureImmediate = vi.fn(async () => {});
74
+ const captureExceptionImmediate = vi.fn(async () => {});
75
+ const client = {
76
+ capture: vi.fn(),
77
+ captureImmediate,
78
+ captureExceptionImmediate,
79
+ };
80
+ const traces = new PosthogTraceTelemetry(client as never, "workspace-1");
81
+ const errors = new PosthogErrorTelemetry(client as never, "workspace-1");
82
+
83
+ await traces.recordGeneration({
84
+ traceId: "trace-1",
85
+ requestId: "request-1",
86
+ executionId: "provider-1",
87
+ provider: "openrouter",
88
+ model: "gpt-5-chat",
89
+ startedAt: "2026-07-13T12:00:00.000Z",
90
+ finishedAt: "2026-07-13T12:00:01.000Z",
91
+ tokensInput: 3,
92
+ tokensOutput: 5,
93
+ cachedTokensInput: 1,
94
+ requestBytes: 40,
95
+ responseBytes: 80,
96
+ input: { prompt: "hello" },
97
+ output: { text: "hi" },
80
98
  });
81
- telemetry.recordError({
82
- message: "request failed",
99
+ await errors.recordError({
100
+ traceId: "trace-1",
83
101
  requestId: "request-1",
84
- executionId: "execution-1",
102
+ executionId: "provider-1",
103
+ message: "upstream unavailable",
85
104
  });
86
105
 
87
- await telemetry.flush();
88
-
89
- expect(errorSpy).toHaveBeenCalledWith(
90
- "[telemetry] OTLP traces export rejected: 401 Unauthorized: invalid authentication",
106
+ expect(captureImmediate).toHaveBeenCalledWith(
107
+ expect.objectContaining({
108
+ distinctId: "workspace-1",
109
+ event: "$ai_generation",
110
+ properties: expect.objectContaining({
111
+ $ai_trace_id: "trace-1",
112
+ $ai_provider: "openrouter",
113
+ $ai_model: "gpt-5-chat",
114
+ $ai_input_tokens: 3,
115
+ $ai_output_tokens: 5,
116
+ }),
117
+ }),
118
+ );
119
+ expect(captureExceptionImmediate).toHaveBeenCalledWith(
120
+ expect.any(Error),
121
+ "workspace-1",
122
+ expect.objectContaining({ "lil.trace_id": "trace-1" }),
91
123
  );
92
- fetchMock.mockRestore();
93
- errorSpy.mockRestore();
94
124
  });
95
125
  });
package/src/otlp.ts DELETED
@@ -1,190 +0,0 @@
1
- export type OtlpSpan = {
2
- traceId: string;
3
- spanId: string;
4
- parentSpanId?: string;
5
- name: string;
6
- startTimeUnixNano: string;
7
- endTimeUnixNano: string;
8
- attributes: OtlpAttribute[];
9
- status?: { code: number; message?: string };
10
- };
11
-
12
- type Environment = Record<string, unknown>;
13
- type OtlpAttribute = {
14
- key: string;
15
- value: { stringValue?: string; intValue?: string; boolValue?: boolean };
16
- };
17
- type OtlpLogRecord = {
18
- timeUnixNano: string;
19
- severityText: "ERROR";
20
- body: { stringValue: string };
21
- attributes: OtlpAttribute[];
22
- };
23
-
24
- export async function exportOtlp(
25
- env: Environment,
26
- signal: "traces" | "errors",
27
- spans: OtlpSpan[],
28
- ): Promise<void> {
29
- if (spans.length === 0) return;
30
- const endpoint = endpointFor(env, signal);
31
- if (!endpoint) return;
32
- try {
33
- const response = await fetch(endpoint, {
34
- method: "POST",
35
- headers: {
36
- "content-type": "application/json",
37
- ...headersFor(env, signal),
38
- },
39
- body: JSON.stringify(otlpPayload(env, signal, spans)),
40
- });
41
- if (!response.ok) {
42
- const responseBody = (await response.text()).slice(0, 2_000);
43
- console.error(
44
- `[telemetry] OTLP ${signal} export rejected: ${response.status} ${response.statusText}${responseBody ? `: ${responseBody}` : ""}`,
45
- );
46
- }
47
- } catch (error) {
48
- console.error("[telemetry] OTLP export failed", error);
49
- }
50
- }
51
-
52
- function otlpPayload(
53
- env: Environment,
54
- signal: "traces" | "errors",
55
- spans: OtlpSpan[],
56
- ): Record<string, unknown> {
57
- const resource = {
58
- attributes: attributes({
59
- "service.name": stringEnv(env, "OTEL_SERVICE_NAME") ?? "neutrome-router",
60
- }),
61
- };
62
- if (signal === "traces") {
63
- return {
64
- resourceSpans: [
65
- {
66
- resource,
67
- scopeSpans: [{ scope: { name: "@neutrome/open-ai-router" }, spans }],
68
- },
69
- ],
70
- };
71
- }
72
- return {
73
- resourceLogs: [
74
- {
75
- resource,
76
- scopeLogs: [
77
- {
78
- scope: { name: "@neutrome/open-ai-router" },
79
- logRecords: spans.map(errorLogRecord),
80
- },
81
- ],
82
- },
83
- ],
84
- };
85
- }
86
-
87
- function errorLogRecord(span: OtlpSpan): OtlpLogRecord {
88
- return {
89
- timeUnixNano: span.endTimeUnixNano,
90
- severityText: "ERROR",
91
- body: { stringValue: span.status?.message ?? span.name },
92
- attributes: span.attributes,
93
- };
94
- }
95
-
96
- export function rootOtlpSpan(input: {
97
- traceId: string;
98
- spanId: string;
99
- startedAt: string;
100
- finishedAt: string;
101
- error?: string;
102
- }): OtlpSpan {
103
- return {
104
- traceId: input.traceId,
105
- spanId: input.spanId,
106
- name: "router.request",
107
- startTimeUnixNano: unixNanos(input.startedAt),
108
- endTimeUnixNano: unixNanos(input.finishedAt),
109
- attributes: [],
110
- ...(input.error ? { status: { code: 2, message: input.error } } : {}),
111
- };
112
- }
113
-
114
- export function attributes(values: Record<string, unknown>): OtlpAttribute[] {
115
- const result: OtlpAttribute[] = [];
116
- for (const [key, value] of Object.entries(values)) {
117
- if (typeof value === "string")
118
- result.push({ key, value: { stringValue: value } });
119
- else if (typeof value === "boolean")
120
- result.push({ key, value: { boolValue: value } });
121
- else if (typeof value === "number" && Number.isFinite(value))
122
- result.push({ key, value: { intValue: String(Math.trunc(value)) } });
123
- }
124
- return result;
125
- }
126
-
127
- export function unixNanos(timestamp: string): string {
128
- return `${Date.parse(timestamp)}000000`;
129
- }
130
- export function randomHex(bytes: number): string {
131
- return [...crypto.getRandomValues(new Uint8Array(bytes))]
132
- .map((byte) => byte.toString(16).padStart(2, "0"))
133
- .join("");
134
- }
135
-
136
- function endpointFor(
137
- env: Environment,
138
- signal: "traces" | "errors",
139
- ): string | undefined {
140
- const endpoint = stringEnv(
141
- env,
142
- signal === "traces"
143
- ? "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
144
- : "OTEL_EXPORTER_OTLP_ERRORS_ENDPOINT",
145
- );
146
- if (endpoint) return endpoint;
147
- const base = stringEnv(env, "OTEL_EXPORTER_OTLP_ENDPOINT");
148
- return base
149
- ? new URL(
150
- "v1/traces",
151
- `${base.endsWith("/") ? base : `${base}/`}`,
152
- ).toString()
153
- : undefined;
154
- }
155
-
156
- function headersFor(
157
- env: Environment,
158
- signal: "traces" | "errors",
159
- ): Record<string, string> {
160
- return parseHeaders(
161
- stringEnv(
162
- env,
163
- signal === "traces"
164
- ? "OTEL_EXPORTER_OTLP_TRACES_HEADERS"
165
- : "OTEL_EXPORTER_OTLP_ERRORS_HEADERS",
166
- ) ?? stringEnv(env, "OTEL_EXPORTER_OTLP_HEADERS"),
167
- );
168
- }
169
-
170
- function parseHeaders(value: string | undefined): Record<string, string> {
171
- if (!value) return {};
172
- return Object.fromEntries(
173
- value.split(",").flatMap((entry) => {
174
- const index = entry.indexOf("=");
175
- return index < 1
176
- ? []
177
- : [
178
- [
179
- entry.slice(0, index).trim(),
180
- decodeURIComponent(entry.slice(index + 1).trim()),
181
- ],
182
- ];
183
- }),
184
- );
185
- }
186
-
187
- function stringEnv(env: Environment, key: string): string | undefined {
188
- const value = env[key];
189
- return typeof value === "string" && value.length > 0 ? value : undefined;
190
- }
package/src/telemetry.ts DELETED
@@ -1,316 +0,0 @@
1
- import type { ExecutionEvent } from "@neutrome/lilsdk-ts";
2
- import {
3
- attributes,
4
- exportOtlp,
5
- randomHex,
6
- rootOtlpSpan,
7
- unixNanos,
8
- type OtlpSpan,
9
- } from "./otlp.ts";
10
-
11
- type TelemetryEnvironment = Record<string, unknown>;
12
-
13
- export type ExecutionSummary = {
14
- spanId: string;
15
- startedAt: string;
16
- finishedAt: string;
17
- spans: ExecutionSummarySpan[];
18
- totalTokensInput: number;
19
- totalTokensOutput: number;
20
- totalCachedTokensInput: number;
21
- error?: string;
22
- };
23
-
24
- export type ExecutionSummarySpan = {
25
- kind: "request" | "provider" | "tool" | "executor";
26
- event: string;
27
- requestId: string;
28
- executionId: string;
29
- parentExecutionId?: string;
30
- model?: string;
31
- provider?: string;
32
- toolName?: string;
33
- executor?: string;
34
- startedAt: string;
35
- finishedAt?: string;
36
- tokensInput: number;
37
- tokensOutput: number;
38
- cachedTokensInput: number;
39
- requestBytes: number;
40
- responseBytes: number;
41
- error?: string;
42
- };
43
-
44
- export type RouterTelemetry = {
45
- observe(event: ExecutionEvent): void;
46
- recordError(input: {
47
- message: string;
48
- requestId: string;
49
- executionId: string;
50
- }): void;
51
- flush(): Promise<void>;
52
- summary(): ExecutionSummary;
53
- };
54
-
55
- export function createRouterTelemetry(
56
- env: TelemetryEnvironment,
57
- ): RouterTelemetry {
58
- const traceId = randomHex(16);
59
- const rootSpanId = randomHex(8);
60
- const startedAt = new Date().toISOString();
61
- const spans: ExecutionSummarySpan[] = [];
62
- const otlpSpans: OtlpSpan[] = [];
63
- const activeSpans = new Map<
64
- string,
65
- { summary: ExecutionSummarySpan; otlp: OtlpSpan }
66
- >();
67
- const executionSpanIds = new Map<string, string>();
68
- const errors: OtlpSpan[] = [];
69
- let error: string | undefined;
70
-
71
- return { observe, recordError, flush, summary };
72
-
73
- function observe(event: ExecutionEvent): void {
74
- const timing = eventTiming(event);
75
- if (isInvocationStarted(event.kind)) {
76
- const pair = createSpan(
77
- event,
78
- timing.startedAt ?? event.timestamp,
79
- undefined,
80
- );
81
- spans.push(pair.summary);
82
- activeSpans.set(event.executionId, pair);
83
- executionSpanIds.set(event.executionId, pair.otlp.spanId);
84
- return;
85
- }
86
- if (isInvocationFinished(event.kind)) {
87
- const active = activeSpans.get(event.executionId);
88
- if (active) {
89
- const finishedAt = timing.finishedAt ?? event.timestamp;
90
- active.summary.finishedAt = finishedAt;
91
- active.otlp.endTimeUnixNano = unixNanos(finishedAt);
92
- activeSpans.delete(event.executionId);
93
- }
94
- return;
95
- }
96
- if (event.kind === "provider.usage") {
97
- const active = activeSpans.get(event.executionId);
98
- if (active) applyUsage(active.summary, active.otlp, event);
99
- return;
100
- }
101
- if (event.kind === "execution.failed") {
102
- const message = stringValue(event.data?.message) ?? "Execution failed";
103
- error ??= message;
104
- const active = activeSpans.get(event.executionId);
105
- if (active)
106
- markSpanError(active.summary, active.otlp, message, event.timestamp);
107
- recordError({
108
- message,
109
- requestId: event.requestId,
110
- executionId: event.executionId,
111
- });
112
- return;
113
- }
114
- if (!timing.startedAt) return;
115
- const pair = createSpan(
116
- event,
117
- timing.startedAt,
118
- timing.finishedAt ?? event.timestamp,
119
- );
120
- spans.push(pair.summary);
121
- }
122
-
123
- function recordError(input: {
124
- message: string;
125
- requestId: string;
126
- executionId: string;
127
- }): void {
128
- error ??= input.message;
129
- const now = new Date().toISOString();
130
- errors.push({
131
- traceId,
132
- spanId: randomHex(8),
133
- parentSpanId: executionSpanIds.get(input.executionId) ?? rootSpanId,
134
- name: "router.error",
135
- startTimeUnixNano: unixNanos(now),
136
- endTimeUnixNano: unixNanos(now),
137
- attributes: attributes({
138
- "error.message": input.message,
139
- "lil.request.id": input.requestId,
140
- "lil.execution.id": input.executionId,
141
- }),
142
- status: { code: 2, message: input.message },
143
- });
144
- }
145
-
146
- async function flush(): Promise<void> {
147
- const finishedAt = new Date().toISOString();
148
- for (const active of activeSpans.values()) {
149
- active.summary.finishedAt ??= finishedAt;
150
- active.otlp.endTimeUnixNano = unixNanos(active.summary.finishedAt);
151
- }
152
- activeSpans.clear();
153
- const root = rootOtlpSpan({
154
- traceId,
155
- spanId: rootSpanId,
156
- startedAt,
157
- finishedAt,
158
- ...(error ? { error } : {}),
159
- });
160
- await Promise.all([
161
- exportOtlp(env, "traces", [root, ...otlpSpans]),
162
- exportOtlp(env, "errors", errors),
163
- ]);
164
- }
165
-
166
- function summary(): ExecutionSummary {
167
- const finishedAt = new Date().toISOString();
168
- return {
169
- spanId: traceId,
170
- startedAt,
171
- finishedAt,
172
- spans,
173
- totalTokensInput: spans.reduce(
174
- (total, span) => total + span.tokensInput,
175
- 0,
176
- ),
177
- totalTokensOutput: spans.reduce(
178
- (total, span) => total + span.tokensOutput,
179
- 0,
180
- ),
181
- totalCachedTokensInput: spans.reduce(
182
- (total, span) => total + span.cachedTokensInput,
183
- 0,
184
- ),
185
- ...(error ? { error } : {}),
186
- };
187
- }
188
-
189
- function createSpan(
190
- event: ExecutionEvent,
191
- startedAt: string,
192
- finishedAt: string | undefined,
193
- ) {
194
- const kind = spanKind(event.kind);
195
- const summary: ExecutionSummarySpan = {
196
- kind,
197
- event: event.kind,
198
- requestId: event.requestId,
199
- executionId: event.executionId,
200
- ...(event.parentExecutionId
201
- ? { parentExecutionId: event.parentExecutionId }
202
- : {}),
203
- ...eventDetails(event),
204
- startedAt,
205
- ...(finishedAt ? { finishedAt } : {}),
206
- tokensInput: 0,
207
- tokensOutput: 0,
208
- cachedTokensInput: 0,
209
- requestBytes: 0,
210
- responseBytes: 0,
211
- };
212
- const parentSpanId = event.parentExecutionId
213
- ? executionSpanIds.get(event.parentExecutionId)
214
- : rootSpanId;
215
- const otlp: OtlpSpan = {
216
- traceId,
217
- spanId: randomHex(8),
218
- ...(parentSpanId ? { parentSpanId } : {}),
219
- name: event.kind,
220
- startTimeUnixNano: unixNanos(startedAt),
221
- endTimeUnixNano: unixNanos(finishedAt ?? startedAt),
222
- attributes: attributes({
223
- "lil.request.id": event.requestId,
224
- "lil.execution.id": event.executionId,
225
- ...event.data,
226
- }),
227
- };
228
- otlpSpans.push(otlp);
229
- return { summary, otlp };
230
- }
231
- }
232
-
233
- function eventTiming(event: ExecutionEvent): {
234
- startedAt: string | undefined;
235
- finishedAt: string | undefined;
236
- } {
237
- return {
238
- startedAt: stringValue(event.data?.startedAt),
239
- finishedAt: stringValue(event.data?.finishedAt),
240
- };
241
- }
242
-
243
- function isInvocationStarted(kind: string): boolean {
244
- return kind.endsWith(".started") || kind.endsWith(".stream_started");
245
- }
246
-
247
- function isInvocationFinished(kind: string): boolean {
248
- return kind.endsWith(".finished") || kind.endsWith(".stream_finished");
249
- }
250
-
251
- function spanKind(kind: string): ExecutionSummarySpan["kind"] {
252
- if (kind.startsWith("provider.")) return "provider";
253
- if (kind.startsWith("tool.")) return "tool";
254
- if (kind.startsWith("executor.")) return "executor";
255
- return "request";
256
- }
257
-
258
- function eventDetails(event: ExecutionEvent): Partial<ExecutionSummarySpan> {
259
- return {
260
- ...optional("model", stringValue(event.data?.model)),
261
- ...optional("provider", stringValue(event.data?.provider)),
262
- ...optional("toolName", stringValue(event.data?.toolName)),
263
- ...optional(
264
- "executor",
265
- stringValue(event.data?.executor) ?? stringValue(event.data?.executorId),
266
- ),
267
- };
268
- }
269
-
270
- function applyUsage(
271
- summary: ExecutionSummarySpan,
272
- span: OtlpSpan,
273
- event: ExecutionEvent,
274
- ): void {
275
- summary.tokensInput = numberValue(event.data?.tokensInput);
276
- summary.tokensOutput = numberValue(event.data?.tokensOutput);
277
- summary.cachedTokensInput = numberValue(event.data?.cachedTokensInput);
278
- summary.requestBytes = numberValue(event.data?.requestBytes);
279
- summary.responseBytes = numberValue(event.data?.responseBytes);
280
- span.attributes.push(
281
- ...attributes({
282
- "gen_ai.usage.input_tokens": summary.tokensInput,
283
- "gen_ai.usage.output_tokens": summary.tokensOutput,
284
- "lil.usage.cached_input_tokens": summary.cachedTokensInput,
285
- "http.request.body.size": summary.requestBytes,
286
- "http.response.body.size": summary.responseBytes,
287
- }),
288
- );
289
- }
290
-
291
- function markSpanError(
292
- summary: ExecutionSummarySpan,
293
- span: OtlpSpan,
294
- message: string,
295
- finishedAt: string,
296
- ): void {
297
- summary.error = message;
298
- summary.finishedAt ??= finishedAt;
299
- span.endTimeUnixNano = unixNanos(summary.finishedAt);
300
- span.status = { code: 2, message };
301
- }
302
-
303
- function stringValue(value: unknown): string | undefined {
304
- return typeof value === "string" && value.length > 0 ? value : undefined;
305
- }
306
- function numberValue(value: unknown): number {
307
- return typeof value === "number" && Number.isFinite(value) && value >= 0
308
- ? value
309
- : 0;
310
- }
311
- function optional<K extends string>(
312
- key: K,
313
- value: string | undefined,
314
- ): Partial<Record<K, string>> {
315
- return value ? ({ [key]: value } as Record<K, string>) : {};
316
- }