Skip to content

Commit fd0e5fe

Browse files
committed
Add OTEL export sink with OTLP HTTP JSON flush
1 parent fbc63c7 commit fd0e5fe

6 files changed

Lines changed: 630 additions & 5 deletions

File tree

docs/PERFTRACE.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ Local measurement does not require any settings or env vars.
2020
Export is **off** until an OTLP endpoint is configured. When enabled, traces go
2121
to the operator-owned backend you point at — not Corbits product analytics.
2222

23-
The settings/env surface is implemented now (`src/perf/otel-config.ts`). The
24-
actual OTLP transport lands in a follow-up (CL-5173). Invalid config fails
25-
closed with a stable error code `OTEL_CONFIG_INVALID` and does not half-enable
26-
export.
23+
The settings/env surface lives in `src/perf/otel-config.ts`. OTLP/HTTP JSON
24+
export (`src/perf/otel-sink.ts`, CL-5173) flushes the PerfSpan tree once at
25+
process exit when export is enabled. Invalid config fails closed with a stable
26+
error code `OTEL_CONFIG_INVALID` and does not half-enable export.
2727

2828
### Configuration
2929

@@ -96,7 +96,8 @@ No endpoint and no half-config → export stays disabled (not an error).
9696
### Targeting common collectors
9797

9898
Examples assume the OTLP HTTP base URL your collector documents. Paths such as
99-
`/v1/traces` are appended by the exporter (CL-5173), not by this settings layer.
99+
`/v1/traces` are appended by the exporter unless the endpoint already ends with
100+
`/v1/traces`.
100101

101102
#### Arize Phoenix
102103

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getLogger } from "@intx/log";
22
import { LOG_NAMESPACE_ROOT } from "./branding.js";
33
import { loadConfig } from "./config/index.js";
44
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
5+
import { flushPerfToOtel } from "./perf/index.js";
56
import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js";
67
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
78
import { runExec } from "./exec/runner.js";
@@ -67,6 +68,11 @@ export async function mainWithRunners(
6768
exitCode = await runners.runTUI(config);
6869
}
6970

71+
// Opt-in OTEL export of the PerfSpan tree (session/process boundary).
72+
// No-op when OTEL is disabled — zero network on the export path.
73+
const otelSettings = config.configured ? config.settings : null;
74+
await flushPerfToOtel(otelSettings);
75+
7076
// Bound against process.exit dropping in-flight captures for short
7177
// sessions; flush itself is deadline-capped so exit stays snappy.
7278
await getTelemetry().flush();

src/perf/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,49 @@ export {
4040
type OtelSettings,
4141
} from "./otel-config.js";
4242

43+
export {
44+
buildOtlpPayload,
45+
flushToOtel,
46+
monoToUnixNano,
47+
newOtelTraceId,
48+
otelSpanId,
49+
otlpTracesUrl,
50+
tagsToOtlpAttributes,
51+
type FlushPerfToOtelOptions,
52+
type FlushToOtelOptions,
53+
type OtlpExportPayload,
54+
type OtlpKeyValue,
55+
type OtlpSpan,
56+
} from "./otel-sink.js";
57+
58+
import type { Settings } from "../config/settings.js";
59+
import {
60+
flushPerfToOtel as flushPerfToOtelImpl,
61+
type FlushPerfToOtelOptions,
62+
} from "./otel-sink.js";
63+
64+
/**
65+
* Snapshot the process-wide ring and POST to the operator OTLP collector when
66+
* export is enabled. Zero network when disabled. Never throws.
67+
* Cadence: call on session/process exit (wired from main).
68+
*/
69+
export async function flushPerfToOtel(
70+
settings?: Settings | null,
71+
env: NodeJS.ProcessEnv = process.env,
72+
options: FlushPerfToOtelOptions = {},
73+
): Promise<void> {
74+
const { spans, getSpans, ...rest } = options;
75+
if (spans !== undefined) {
76+
await flushPerfToOtelImpl(settings, env, { ...rest, spans });
77+
return;
78+
}
79+
if (getSpans !== undefined) {
80+
await flushPerfToOtelImpl(settings, env, { ...rest, getSpans });
81+
return;
82+
}
83+
await flushPerfToOtelImpl(settings, env, { ...rest, getSpans: snapshot });
84+
}
85+
4386
/** Core + adapter phase names. Adapters extend; they do not invent new sinks. */
4487
export const SPAN_NAMES = [
4588
"session",

src/perf/otel-sink.test.ts

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import type { Settings } from "../config/settings.js";
4+
import { clear, end, snapshot, start, type PerfSpan } from "./index.js";
5+
import {
6+
buildOtlpPayload,
7+
flushPerfToOtel,
8+
flushToOtel,
9+
monoToUnixNano,
10+
otelSpanId,
11+
otlpTracesUrl,
12+
tagsToOtlpAttributes,
13+
} from "./otel-sink.js";
14+
import type { EnabledOtelExportConfig } from "./otel-config.js";
15+
16+
afterEach(() => {
17+
clear();
18+
});
19+
20+
const enabledConfig = (
21+
overrides: Partial<EnabledOtelExportConfig> = {},
22+
): EnabledOtelExportConfig => ({
23+
enabled: true,
24+
endpoint: "http://localhost:4318",
25+
headers: {},
26+
serviceName: "corbits-code",
27+
resourceAttributes: { "service.name": "corbits-code" },
28+
...overrides,
29+
});
30+
31+
const baseSettings = (otel?: Settings["otel"]): Settings => ({
32+
providers: {},
33+
...(otel !== undefined ? { otel } : {}),
34+
});
35+
36+
const mockFetch = (impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) =>
37+
impl as unknown as typeof fetch;
38+
39+
describe("otlpTracesUrl", () => {
40+
test("appends /v1/traces to base endpoint", () => {
41+
expect(otlpTracesUrl("http://localhost:4318")).toBe("http://localhost:4318/v1/traces");
42+
});
43+
44+
test("does not double-append when path already ends with /v1/traces", () => {
45+
expect(otlpTracesUrl("https://app.phoenix.arize.com/v1/traces")).toBe(
46+
"https://app.phoenix.arize.com/v1/traces",
47+
);
48+
});
49+
50+
test("strips trailing slash before appending", () => {
51+
expect(otlpTracesUrl("http://localhost:4318/")).toBe("http://localhost:4318/v1/traces");
52+
});
53+
});
54+
55+
describe("otelSpanId", () => {
56+
test("is 16 hex chars and stable", () => {
57+
const a = otelSpanId("1");
58+
const b = otelSpanId("1");
59+
expect(a).toMatch(/^[0-9a-f]{16}$/);
60+
expect(a).toBe(b);
61+
expect(otelSpanId("2")).not.toBe(a);
62+
});
63+
});
64+
65+
describe("tagsToOtlpAttributes", () => {
66+
test("maps string and integer tags", () => {
67+
const attrs = tagsToOtlpAttributes({
68+
provider_id: "openai",
69+
count: 3,
70+
transport: "ws",
71+
});
72+
expect(attrs).toEqual([
73+
{ key: "count", value: { intValue: "3" } },
74+
{ key: "provider_id", value: { stringValue: "openai" } },
75+
{ key: "transport", value: { stringValue: "ws" } },
76+
]);
77+
});
78+
79+
test("re-sanitizes forbidden keys at export", () => {
80+
const attrs = tagsToOtlpAttributes({
81+
provider_id: "xai",
82+
prompt: "secret",
83+
path: "/tmp/x",
84+
} as never);
85+
expect(attrs).toEqual([{ key: "provider_id", value: { stringValue: "xai" } }]);
86+
});
87+
});
88+
89+
describe("buildOtlpPayload", () => {
90+
test("maps parent links, names, and times", () => {
91+
const turnId = start("turn");
92+
const infId = start("inference", { parentId: turnId, tags: { model_id: "m1" } });
93+
end(infId);
94+
end(turnId);
95+
const spans: PerfSpan[] = [
96+
{
97+
id: turnId,
98+
name: "turn",
99+
startNs: 1000n,
100+
endNs: 5000n,
101+
},
102+
{
103+
id: infId,
104+
name: "inference",
105+
parentId: turnId,
106+
startNs: 2000n,
107+
endNs: 4000n,
108+
tags: { model_id: "m1" },
109+
},
110+
];
111+
112+
const anchor = { monoNs: 0n, unixNs: 1_000_000_000_000n };
113+
const payload = buildOtlpPayload(spans, enabledConfig(), {
114+
wallAnchor: anchor,
115+
traceId: "a".repeat(32),
116+
});
117+
118+
const otlpSpans = payload.resourceSpans[0]!.scopeSpans[0]!.spans;
119+
expect(otlpSpans).toHaveLength(2);
120+
121+
const turn = otlpSpans.find((s) => s.name === "turn")!;
122+
const inf = otlpSpans.find((s) => s.name === "inference")!;
123+
expect(turn.traceId).toBe("a".repeat(32));
124+
expect(turn.spanId).toBe(otelSpanId(turnId));
125+
expect(turn.parentSpanId).toBeUndefined();
126+
expect(turn.startTimeUnixNano).toBe(monoToUnixNano(1000n, anchor).toString());
127+
expect(turn.endTimeUnixNano).toBe(monoToUnixNano(5000n, anchor).toString());
128+
129+
expect(inf.parentSpanId).toBe(otelSpanId(turnId));
130+
expect(inf.attributes).toEqual([{ key: "model_id", value: { stringValue: "m1" } }]);
131+
132+
const resource = payload.resourceSpans[0]!.resource.attributes;
133+
expect(
134+
resource.some(
135+
(a) => a.key === "service.name" && "stringValue" in a.value && a.value.stringValue === "corbits-code",
136+
),
137+
).toBe(true);
138+
});
139+
140+
test("open spans use nowMonoNs as end", () => {
141+
const spans: PerfSpan[] = [{ id: "open1", name: "session", startNs: 10n }];
142+
const anchor = { monoNs: 0n, unixNs: 0n };
143+
const payload = buildOtlpPayload(spans, enabledConfig(), {
144+
wallAnchor: anchor,
145+
nowMonoNs: () => 99n,
146+
traceId: "b".repeat(32),
147+
});
148+
const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!;
149+
expect(span.startTimeUnixNano).toBe("10");
150+
expect(span.endTimeUnixNano).toBe("99");
151+
});
152+
});
153+
154+
describe("flushToOtel", () => {
155+
test("POSTs OTLP JSON with headers to /v1/traces", async () => {
156+
const calls: Array<{ url: string; init: RequestInit }> = [];
157+
const fetchFn = mockFetch(async (input, init) => {
158+
calls.push({ url: String(input), init: init ?? {} });
159+
return new Response(null, { status: 200 });
160+
});
161+
162+
const spans: PerfSpan[] = [{ id: "1", name: "turn", startNs: 1n, endNs: 2n }];
163+
await flushToOtel(
164+
spans,
165+
enabledConfig({
166+
endpoint: "https://collector.example",
167+
headers: { Authorization: "Bearer secret" },
168+
}),
169+
{ fetchFn, traceId: "c".repeat(32), wallAnchor: { monoNs: 0n, unixNs: 0n } },
170+
);
171+
172+
expect(calls).toHaveLength(1);
173+
expect(calls[0]!.url).toBe("https://collector.example/v1/traces");
174+
const headers = calls[0]!.init.headers as Record<string, string>;
175+
expect(headers["content-type"]).toBe("application/json");
176+
expect(headers.Authorization).toBe("Bearer secret");
177+
178+
const body = JSON.parse(String(calls[0]!.init.body)) as {
179+
resourceSpans: unknown[];
180+
};
181+
expect(body.resourceSpans).toHaveLength(1);
182+
});
183+
184+
test("empty spans do not call fetch", async () => {
185+
let called = 0;
186+
const fetchFn = mockFetch(async () => {
187+
called += 1;
188+
return new Response(null, { status: 200 });
189+
});
190+
await flushToOtel([], enabledConfig(), { fetchFn });
191+
expect(called).toBe(0);
192+
});
193+
194+
test("network errors are swallowed", async () => {
195+
const fetchFn = mockFetch(async () => {
196+
throw new Error("ECONNREFUSED");
197+
});
198+
await expect(
199+
flushToOtel([{ id: "1", name: "turn", startNs: 1n, endNs: 2n }], enabledConfig(), {
200+
fetchFn,
201+
}),
202+
).resolves.toBeUndefined();
203+
});
204+
205+
test("non-2xx responses are swallowed", async () => {
206+
const fetchFn = mockFetch(async () => new Response("nope", { status: 503 }));
207+
await expect(
208+
flushToOtel([{ id: "1", name: "turn", startNs: 1n, endNs: 2n }], enabledConfig(), {
209+
fetchFn,
210+
}),
211+
).resolves.toBeUndefined();
212+
});
213+
});
214+
215+
describe("flushPerfToOtel", () => {
216+
test("disabled config performs zero network", async () => {
217+
let called = 0;
218+
const fetchFn = mockFetch(async () => {
219+
called += 1;
220+
return new Response(null, { status: 200 });
221+
});
222+
223+
const id = start("turn");
224+
end(id);
225+
await flushPerfToOtel(baseSettings(), {}, { fetchFn });
226+
expect(called).toBe(0);
227+
});
228+
229+
test("enabled config snapshots and POSTs", async () => {
230+
const calls: string[] = [];
231+
const fetchFn = mockFetch(async (input) => {
232+
calls.push(String(input));
233+
return new Response(null, { status: 200 });
234+
});
235+
236+
const id = start("session");
237+
end(id);
238+
239+
await flushPerfToOtel(baseSettings({ endpoint: "http://127.0.0.1:4318" }), {}, { fetchFn, getSpans: snapshot });
240+
expect(calls).toEqual(["http://127.0.0.1:4318/v1/traces"]);
241+
});
242+
243+
test("invalid config does not throw and does not fetch", async () => {
244+
let called = 0;
245+
const fetchFn = mockFetch(async () => {
246+
called += 1;
247+
return new Response(null, { status: 200 });
248+
});
249+
250+
await expect(
251+
flushPerfToOtel(baseSettings({ enabled: true }), {}, { fetchFn }),
252+
).resolves.toBeUndefined();
253+
expect(called).toBe(0);
254+
});
255+
256+
test("explicit spans override ring snapshot", async () => {
257+
const bodies: string[] = [];
258+
const fetchFn = mockFetch(async (_input, init) => {
259+
bodies.push(String(init?.body ?? ""));
260+
return new Response(null, { status: 200 });
261+
});
262+
263+
// Ring has a session span — should be ignored when spans is provided.
264+
start("session");
265+
const only: PerfSpan[] = [
266+
{ id: "only", name: "tool", startNs: 1n, endNs: 2n, tags: { tool_id: "t1" } },
267+
];
268+
await flushPerfToOtel(
269+
baseSettings({ endpoint: "http://localhost:4318" }),
270+
{},
271+
{
272+
fetchFn,
273+
spans: only,
274+
traceId: "d".repeat(32),
275+
wallAnchor: { monoNs: 0n, unixNs: 0n },
276+
},
277+
);
278+
expect(bodies).toHaveLength(1);
279+
const parsed = JSON.parse(bodies[0]!) as {
280+
resourceSpans: Array<{
281+
scopeSpans: Array<{ spans: Array<{ name: string }> }>;
282+
}>;
283+
};
284+
const names = parsed.resourceSpans[0]!.scopeSpans[0]!.spans.map((s) => s.name);
285+
expect(names).toEqual(["tool"]);
286+
});
287+
});

0 commit comments

Comments
 (0)