@neutrome/open-ai-router 0.5.3 → 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 +4 -1
- package/src/app/factory.test.ts +1 -1
- package/src/app/factory.ts +7 -2
- package/src/app/types.ts +2 -1
- package/src/index.ts +8 -2
- package/src/router/provider-invoker.ts +10 -0
- package/src/router/provider-stream.ts +17 -0
- package/src/router/provider-telemetry.ts +7 -1
- package/src/telemetry/index.ts +13 -0
- package/src/telemetry/posthog.ts +108 -0
- package/src/telemetry/router.ts +291 -0
- package/src/telemetry/types.ts +33 -0
- package/src/telemetry.test.ts +95 -47
- package/src/otlp.ts +0 -189
- package/src/telemetry.ts +0 -316
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.5.
|
|
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
|
},
|
package/src/app/factory.test.ts
CHANGED
|
@@ -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
|
|
package/src/app/factory.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/telemetry.test.ts
CHANGED
|
@@ -1,77 +1,125 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
1
|
import { createExecutionEvent } from "@neutrome/lilsdk-ts";
|
|
3
|
-
import {
|
|
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("
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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: {
|
|
20
|
+
data: {
|
|
21
|
+
startedAt: "2026-07-13T12:00:00.000Z",
|
|
22
|
+
provider: "openrouter",
|
|
23
|
+
model: "gpt-5-chat",
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
telemetry.observe(
|
|
28
|
+
createExecutionEvent({
|
|
29
|
+
kind: "provider.usage",
|
|
30
|
+
requestId: "request-1",
|
|
31
|
+
executionId: "provider-1",
|
|
32
|
+
data: {
|
|
33
|
+
tokensInput: 12,
|
|
34
|
+
tokensOutput: 8,
|
|
35
|
+
input: { messages: [{ role: "user", content: "hello" }] },
|
|
36
|
+
output: { choices: [{ message: { content: "hi" } }] },
|
|
37
|
+
},
|
|
22
38
|
}),
|
|
23
39
|
);
|
|
24
40
|
telemetry.observe(
|
|
25
41
|
createExecutionEvent({
|
|
26
|
-
kind: "
|
|
42
|
+
kind: "provider.finished",
|
|
27
43
|
requestId: "request-1",
|
|
28
44
|
executionId: "provider-1",
|
|
29
|
-
data: {
|
|
45
|
+
data: { finishedAt: "2026-07-13T12:00:01.000Z" },
|
|
30
46
|
}),
|
|
31
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(
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
40
|
-
|
|
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(fetchMock.mock.calls[1]?.[1]?.body).toContain("resourceLogs");
|
|
49
|
-
fetchMock.mockRestore();
|
|
50
68
|
});
|
|
69
|
+
});
|
|
70
|
+
|
|
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");
|
|
51
82
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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" },
|
|
62
98
|
});
|
|
63
|
-
|
|
64
|
-
|
|
99
|
+
await errors.recordError({
|
|
100
|
+
traceId: "trace-1",
|
|
65
101
|
requestId: "request-1",
|
|
66
|
-
executionId: "
|
|
102
|
+
executionId: "provider-1",
|
|
103
|
+
message: "upstream unavailable",
|
|
67
104
|
});
|
|
68
105
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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" }),
|
|
73
123
|
);
|
|
74
|
-
fetchMock.mockRestore();
|
|
75
|
-
errorSpy.mockRestore();
|
|
76
124
|
});
|
|
77
125
|
});
|
package/src/otlp.ts
DELETED
|
@@ -1,189 +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
|
-
console.error(
|
|
43
|
-
`[telemetry] OTLP ${signal} export rejected: ${response.status} ${response.statusText}`,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
} catch (error) {
|
|
47
|
-
console.error("[telemetry] OTLP export failed", error);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function otlpPayload(
|
|
52
|
-
env: Environment,
|
|
53
|
-
signal: "traces" | "errors",
|
|
54
|
-
spans: OtlpSpan[],
|
|
55
|
-
): Record<string, unknown> {
|
|
56
|
-
const resource = {
|
|
57
|
-
attributes: attributes({
|
|
58
|
-
"service.name": stringEnv(env, "OTEL_SERVICE_NAME") ?? "neutrome-router",
|
|
59
|
-
}),
|
|
60
|
-
};
|
|
61
|
-
if (signal === "traces") {
|
|
62
|
-
return {
|
|
63
|
-
resourceSpans: [
|
|
64
|
-
{
|
|
65
|
-
resource,
|
|
66
|
-
scopeSpans: [{ scope: { name: "@neutrome/open-ai-router" }, spans }],
|
|
67
|
-
},
|
|
68
|
-
],
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
resourceLogs: [
|
|
73
|
-
{
|
|
74
|
-
resource,
|
|
75
|
-
scopeLogs: [
|
|
76
|
-
{
|
|
77
|
-
scope: { name: "@neutrome/open-ai-router" },
|
|
78
|
-
logRecords: spans.map(errorLogRecord),
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function errorLogRecord(span: OtlpSpan): OtlpLogRecord {
|
|
87
|
-
return {
|
|
88
|
-
timeUnixNano: span.endTimeUnixNano,
|
|
89
|
-
severityText: "ERROR",
|
|
90
|
-
body: { stringValue: span.status?.message ?? span.name },
|
|
91
|
-
attributes: span.attributes,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function rootOtlpSpan(input: {
|
|
96
|
-
traceId: string;
|
|
97
|
-
spanId: string;
|
|
98
|
-
startedAt: string;
|
|
99
|
-
finishedAt: string;
|
|
100
|
-
error?: string;
|
|
101
|
-
}): OtlpSpan {
|
|
102
|
-
return {
|
|
103
|
-
traceId: input.traceId,
|
|
104
|
-
spanId: input.spanId,
|
|
105
|
-
name: "router.request",
|
|
106
|
-
startTimeUnixNano: unixNanos(input.startedAt),
|
|
107
|
-
endTimeUnixNano: unixNanos(input.finishedAt),
|
|
108
|
-
attributes: [],
|
|
109
|
-
...(input.error ? { status: { code: 2, message: input.error } } : {}),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function attributes(values: Record<string, unknown>): OtlpAttribute[] {
|
|
114
|
-
const result: OtlpAttribute[] = [];
|
|
115
|
-
for (const [key, value] of Object.entries(values)) {
|
|
116
|
-
if (typeof value === "string")
|
|
117
|
-
result.push({ key, value: { stringValue: value } });
|
|
118
|
-
else if (typeof value === "boolean")
|
|
119
|
-
result.push({ key, value: { boolValue: value } });
|
|
120
|
-
else if (typeof value === "number" && Number.isFinite(value))
|
|
121
|
-
result.push({ key, value: { intValue: String(Math.trunc(value)) } });
|
|
122
|
-
}
|
|
123
|
-
return result;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function unixNanos(timestamp: string): string {
|
|
127
|
-
return `${Date.parse(timestamp)}000000`;
|
|
128
|
-
}
|
|
129
|
-
export function randomHex(bytes: number): string {
|
|
130
|
-
return [...crypto.getRandomValues(new Uint8Array(bytes))]
|
|
131
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
132
|
-
.join("");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function endpointFor(
|
|
136
|
-
env: Environment,
|
|
137
|
-
signal: "traces" | "errors",
|
|
138
|
-
): string | undefined {
|
|
139
|
-
const endpoint = stringEnv(
|
|
140
|
-
env,
|
|
141
|
-
signal === "traces"
|
|
142
|
-
? "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
|
|
143
|
-
: "OTEL_EXPORTER_OTLP_ERRORS_ENDPOINT",
|
|
144
|
-
);
|
|
145
|
-
if (endpoint) return endpoint;
|
|
146
|
-
const base = stringEnv(env, "OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
147
|
-
return base
|
|
148
|
-
? new URL(
|
|
149
|
-
"v1/traces",
|
|
150
|
-
`${base.endsWith("/") ? base : `${base}/`}`,
|
|
151
|
-
).toString()
|
|
152
|
-
: undefined;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function headersFor(
|
|
156
|
-
env: Environment,
|
|
157
|
-
signal: "traces" | "errors",
|
|
158
|
-
): Record<string, string> {
|
|
159
|
-
return parseHeaders(
|
|
160
|
-
stringEnv(
|
|
161
|
-
env,
|
|
162
|
-
signal === "traces"
|
|
163
|
-
? "OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
|
164
|
-
: "OTEL_EXPORTER_OTLP_ERRORS_HEADERS",
|
|
165
|
-
) ?? stringEnv(env, "OTEL_EXPORTER_OTLP_HEADERS"),
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function parseHeaders(value: string | undefined): Record<string, string> {
|
|
170
|
-
if (!value) return {};
|
|
171
|
-
return Object.fromEntries(
|
|
172
|
-
value.split(",").flatMap((entry) => {
|
|
173
|
-
const index = entry.indexOf("=");
|
|
174
|
-
return index < 1
|
|
175
|
-
? []
|
|
176
|
-
: [
|
|
177
|
-
[
|
|
178
|
-
entry.slice(0, index).trim(),
|
|
179
|
-
decodeURIComponent(entry.slice(index + 1).trim()),
|
|
180
|
-
],
|
|
181
|
-
];
|
|
182
|
-
}),
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function stringEnv(env: Environment, key: string): string | undefined {
|
|
187
|
-
const value = env[key];
|
|
188
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
189
|
-
}
|
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
|
-
}
|