Skip to content

Commit 65c6f8d

Browse files
authored
Merge pull request #518 from alectimison-maker/feat/otlp-trace-converter
feat(traces): add offline OTLP converter
2 parents 86dc7eb + 372e4f9 commit 65c6f8d

4 files changed

Lines changed: 550 additions & 0 deletions

File tree

docs/export-and-workflow-formats.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,53 @@ Screenshot events can include `screenshot_base64` or `screenshot_dataUrl`.
5959
Consumers should check `schema`, tolerate additional fields, and avoid relying
6060
on undocumented event internals.
6161

62+
### Convert a trace to OpenTelemetry OTLP JSON
63+
64+
The repository includes an offline converter for sending an exported
65+
`webbrain-trace/1` run through an OpenTelemetry-compatible observability
66+
pipeline:
67+
68+
```sh
69+
npm run trace:otlp -- webbrain-trace-example.json \
70+
--output webbrain-trace-example.otlp.json
71+
```
72+
73+
The output is an
74+
[OTLP/HTTP JSON](https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding)
75+
`ExportTraceServiceRequest`. It contains one `invoke_agent WebBrain` root span,
76+
child model-call and `execute_tool` spans, and lightweight lifecycle events.
77+
The mappings follow the current
78+
[OpenTelemetry GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md),
79+
which are still marked development and may change.
80+
81+
The converter does not upload anything. To send the result to a collector, use
82+
that collector's OTLP/HTTP traces endpoint; for example, for a trusted local
83+
collector:
84+
85+
```sh
86+
curl -H 'Content-Type: application/json' \
87+
--data-binary @webbrain-trace-example.otlp.json \
88+
http://127.0.0.1:4318/v1/traces
89+
```
90+
91+
Content capture is off by default. User messages, assistant text, error
92+
messages, tool arguments, and tool results are omitted, while operational
93+
metadata such as model, provider, timings, token counts, tool names, and run
94+
identifiers remains. Screenshots are never embedded in the OTLP output. Add
95+
`--include-content` only when the destination is trusted and the trace has been
96+
reviewed:
97+
98+
```sh
99+
npm run trace:otlp -- webbrain-trace-example.json \
100+
--output webbrain-trace-example.otlp.json \
101+
--include-content
102+
```
103+
104+
This is a post-run conversion, not live OpenTelemetry instrumentation. Child
105+
span start times are reconstructed from the recorder's completion timestamp and
106+
reported latency, so they should be used for diagnostics rather than
107+
distributed context propagation.
108+
62109
## Settings snapshots: `webbrain-config/1`
63110

64111
`/export --config` creates a versioned Settings snapshot:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"build:web:landing": "node web/build/build.mjs",
2121
"build:web": "npm run build:web:landing && npm run build:blog && npm run build:docs",
2222
"build:zip": "node scripts/build-zip.mjs",
23+
"trace:otlp": "node scripts/trace-to-otlp.mjs",
2324
"sync:logo": "python3 scripts/sync-logo-assets.py && python3 scripts/gen-store-promos.py && node assets/webstore-explainer-2026/render.mjs",
2425
"bump": "node scripts/bump-version.mjs",
2526
"release": "node scripts/bump-version.mjs --release"

scripts/trace-to-otlp.mjs

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
#!/usr/bin/env node
2+
import { createHash } from 'node:crypto';
3+
import { readFileSync, writeFileSync } from 'node:fs';
4+
import { pathToFileURL } from 'node:url';
5+
6+
const WEBBRAIN_TRACE_SCHEMA = 'webbrain-trace/1';
7+
const CONTENT_LIMIT = 20_000;
8+
const SPAN_KIND_INTERNAL = 1;
9+
const SPAN_KIND_CLIENT = 3;
10+
const STATUS_CODE_ERROR = 2;
11+
12+
function finiteNumber(value, fallback = 0) {
13+
const number = Number(value);
14+
return Number.isFinite(number) ? number : fallback;
15+
}
16+
17+
function unixNano(milliseconds) {
18+
const micros = BigInt(Math.max(0, Math.round(finiteNumber(milliseconds) * 1000)));
19+
return String(micros * 1000n);
20+
}
21+
22+
function stableHex(seed, length) {
23+
const value = createHash('sha256').update(String(seed)).digest('hex').slice(0, length);
24+
return /^0+$/.test(value) ? `${'0'.repeat(length - 1)}1` : value;
25+
}
26+
27+
function safeJson(value) {
28+
try {
29+
const serialized = JSON.stringify(value);
30+
return serialized === undefined ? '' : serialized;
31+
} catch {
32+
try {
33+
return String(value);
34+
} catch {
35+
return '(unserializable)';
36+
}
37+
}
38+
}
39+
40+
function boundedContent(value) {
41+
if (value == null) return '';
42+
const text = typeof value === 'string' ? value : safeJson(value);
43+
if (text.length <= CONTENT_LIMIT) return text;
44+
return `${text.slice(0, CONTENT_LIMIT)}…`;
45+
}
46+
47+
function anyValue(value) {
48+
if (typeof value === 'boolean') return { boolValue: value };
49+
if (typeof value === 'number') {
50+
if (!Number.isFinite(value)) return null;
51+
return Number.isInteger(value)
52+
? { intValue: String(value) }
53+
: { doubleValue: value };
54+
}
55+
if (value == null || value === '') return null;
56+
return { stringValue: String(value) };
57+
}
58+
59+
function attributes(entries) {
60+
return entries.flatMap(([key, value]) => {
61+
const encoded = anyValue(value);
62+
return encoded ? [{ key, value: encoded }] : [];
63+
});
64+
}
65+
66+
function eventTime(event, fallback) {
67+
const value = finiteNumber(event?.ts, fallback);
68+
return value > 0 ? value : fallback;
69+
}
70+
71+
function spanBounds(event, runStart) {
72+
const end = eventTime(event, runStart);
73+
const latency = Math.max(0, finiteNumber(event?.data?.latencyMs));
74+
return {
75+
startTimeUnixNano: unixNano(Math.max(runStart, end - latency)),
76+
endTimeUnixNano: unixNano(Math.max(runStart, end)),
77+
};
78+
}
79+
80+
function failedToolResult(result) {
81+
if (result == null) return true;
82+
return typeof result === 'object' && (result.success === false || Boolean(result.error));
83+
}
84+
85+
function contentAttributes(run, includeContent) {
86+
if (!includeContent) return [];
87+
return [
88+
['webbrain.user.message', boundedContent(run.userMessage)],
89+
['webbrain.final.response', boundedContent(run.finalContent)],
90+
];
91+
}
92+
93+
function rootEvents(events, includeContent, runStart) {
94+
return events.flatMap((event) => {
95+
const data = event?.data || {};
96+
const base = [
97+
['webbrain.event.sequence', finiteNumber(event?.seq)],
98+
['webbrain.step', finiteNumber(data.step)],
99+
];
100+
if (event?.kind === 'error') {
101+
return [{
102+
timeUnixNano: unixNano(eventTime(event, runStart)),
103+
name: 'exception',
104+
attributes: attributes([
105+
...base,
106+
['exception.type', data.phase ? `webbrain.${data.phase}` : 'webbrain.error'],
107+
...(includeContent ? [['exception.message', boundedContent(data.message)]] : []),
108+
]),
109+
}];
110+
}
111+
if (event?.kind === 'streaming' || event?.kind === 'note' || event?.kind === 'screenshot') {
112+
return [{
113+
timeUnixNano: unixNano(eventTime(event, runStart)),
114+
name: `webbrain.${event.kind}`,
115+
attributes: attributes([
116+
...base,
117+
['webbrain.event.status', data.status],
118+
['webbrain.event.reason', data.reason],
119+
...(includeContent && event.kind === 'note'
120+
? [['webbrain.note', boundedContent(data.note)]]
121+
: []),
122+
]),
123+
}];
124+
}
125+
return [];
126+
});
127+
}
128+
129+
function inferenceSpan(event, context, includeContent) {
130+
const data = event.data || {};
131+
const model = String(data.model || context.run.model || 'unknown');
132+
const usage = data.usage || {};
133+
return {
134+
traceId: context.traceId,
135+
spanId: stableHex(`${context.run.runId}:llm_response:${event.seq}`, 16),
136+
parentSpanId: context.rootSpanId,
137+
name: `chat ${model}`,
138+
kind: SPAN_KIND_CLIENT,
139+
...spanBounds(event, context.runStart),
140+
attributes: attributes([
141+
['gen_ai.operation.name', 'chat'],
142+
['gen_ai.provider.name', context.run.providerId],
143+
['gen_ai.request.model', model],
144+
['gen_ai.usage.input_tokens', usage.prompt_tokens],
145+
['gen_ai.usage.output_tokens', usage.completion_tokens],
146+
['webbrain.event.sequence', finiteNumber(event.seq)],
147+
['webbrain.step', finiteNumber(data.step)],
148+
...(includeContent
149+
? [['webbrain.llm.response.content', boundedContent(data.content)]]
150+
: []),
151+
]),
152+
};
153+
}
154+
155+
function toolSpan(event, context, includeContent) {
156+
const data = event.data || {};
157+
const name = String(data.name || 'unknown');
158+
const failed = failedToolResult(data.result);
159+
return {
160+
traceId: context.traceId,
161+
spanId: stableHex(`${context.run.runId}:tool:${event.seq}`, 16),
162+
parentSpanId: context.rootSpanId,
163+
name: `execute_tool ${name}`,
164+
kind: SPAN_KIND_INTERNAL,
165+
...spanBounds(event, context.runStart),
166+
attributes: attributes([
167+
['gen_ai.operation.name', 'execute_tool'],
168+
['gen_ai.tool.name', name],
169+
['gen_ai.agent.name', 'WebBrain'],
170+
...(failed ? [['error.type', 'tool_error']] : []),
171+
['webbrain.event.sequence', finiteNumber(event.seq)],
172+
['webbrain.step', finiteNumber(data.step)],
173+
...(includeContent
174+
? [
175+
['gen_ai.tool.call.arguments', boundedContent(data.args)],
176+
['gen_ai.tool.call.result', boundedContent(data.result)],
177+
]
178+
: []),
179+
]),
180+
...(failed ? { status: { code: STATUS_CODE_ERROR } } : {}),
181+
};
182+
}
183+
184+
export function traceExportToOtlp(input, { includeContent = false } = {}) {
185+
if (!input || input.schema !== WEBBRAIN_TRACE_SCHEMA) {
186+
throw new Error(`Expected a ${WEBBRAIN_TRACE_SCHEMA} export.`);
187+
}
188+
if (!input.run || typeof input.run !== 'object' || Array.isArray(input.run)) {
189+
throw new Error('Trace export must contain a run object.');
190+
}
191+
if (!Array.isArray(input.events)) {
192+
throw new Error('Trace export must contain an events array.');
193+
}
194+
195+
const run = input.run;
196+
const events = [...input.events]
197+
.filter((event) => event && typeof event === 'object')
198+
.sort((a, b) => finiteNumber(a.seq) - finiteNumber(b.seq));
199+
const eventTimes = events.map((event) => finiteNumber(event.ts)).filter((time) => time > 0);
200+
const fallbackStarts = [...eventTimes, finiteNumber(input.exportedAt)].filter((time) => time > 0);
201+
const initialStart = finiteNumber(
202+
run.startedAt,
203+
fallbackStarts.length ? Math.min(...fallbackStarts) : 0,
204+
);
205+
const childStarts = events.map((event) => (
206+
eventTime(event, initialStart) - Math.max(0, finiteNumber(event?.data?.latencyMs))
207+
));
208+
const runStart = Math.max(0, Math.min(initialStart, ...childStarts));
209+
const runEnd = Math.max(
210+
runStart,
211+
finiteNumber(run.endedAt),
212+
runStart + Math.max(0, finiteNumber(run.durationMs)),
213+
...eventTimes,
214+
);
215+
const traceId = stableHex(`webbrain:${run.runId || runStart}`, 32);
216+
const rootSpanId = stableHex(`webbrain:${run.runId || runStart}:root`, 16);
217+
const context = { run, runStart, traceId, rootSpanId };
218+
const hasError = ['error', 'failed', 'loop_stopped'].includes(String(run.status || '').toLowerCase())
219+
|| events.some((event) => event.kind === 'error');
220+
221+
const root = {
222+
traceId,
223+
spanId: rootSpanId,
224+
name: 'invoke_agent WebBrain',
225+
kind: SPAN_KIND_INTERNAL,
226+
startTimeUnixNano: unixNano(runStart),
227+
endTimeUnixNano: unixNano(runEnd),
228+
attributes: attributes([
229+
['gen_ai.operation.name', 'invoke_agent'],
230+
['gen_ai.agent.name', 'WebBrain'],
231+
['gen_ai.agent.version', run.webbrainVersion || input.exportedByWebBrainVersion],
232+
['gen_ai.provider.name', run.providerId],
233+
['gen_ai.request.model', run.model],
234+
['gen_ai.conversation.id', run.conversationId],
235+
['gen_ai.usage.input_tokens', run.totalInputTokens],
236+
['gen_ai.usage.output_tokens', run.totalOutputTokens],
237+
['webbrain.run.id', run.runId],
238+
['webbrain.run.status', run.status],
239+
...contentAttributes(run, includeContent),
240+
]),
241+
events: rootEvents(events, includeContent, runStart),
242+
...(hasError ? { status: { code: STATUS_CODE_ERROR } } : {}),
243+
};
244+
const childSpans = events.flatMap((event) => {
245+
if (event.kind === 'llm_response') return [inferenceSpan(event, context, includeContent)];
246+
if (event.kind === 'tool') return [toolSpan(event, context, includeContent)];
247+
return [];
248+
});
249+
250+
return {
251+
resourceSpans: [{
252+
resource: {
253+
attributes: attributes([
254+
['service.name', 'webbrain'],
255+
['service.version', run.webbrainVersion || input.exportedByWebBrainVersion],
256+
]),
257+
},
258+
scopeSpans: [{
259+
scope: {
260+
name: 'webbrain.trace-export',
261+
...(input.exportedByWebBrainVersion
262+
? { version: String(input.exportedByWebBrainVersion) }
263+
: {}),
264+
},
265+
spans: [root, ...childSpans],
266+
}],
267+
}],
268+
};
269+
}
270+
271+
export function parseTraceToOtlpArgs(argv) {
272+
const parsed = { input: '', output: '', includeContent: false };
273+
for (let index = 0; index < argv.length; index += 1) {
274+
const arg = argv[index];
275+
if (arg === '--include-content') {
276+
parsed.includeContent = true;
277+
} else if (arg === '--output') {
278+
const value = argv[index + 1];
279+
if (!value || value.startsWith('--')) throw new Error('Missing value for --output.');
280+
parsed.output = value;
281+
index += 1;
282+
} else if (arg.startsWith('--')) {
283+
throw new Error(`Unknown option: ${arg}`);
284+
} else if (!parsed.input) {
285+
parsed.input = arg;
286+
} else {
287+
throw new Error(`Unexpected argument: ${arg}`);
288+
}
289+
}
290+
if (!parsed.input) throw new Error('Missing input trace JSON path.');
291+
return parsed;
292+
}
293+
294+
function runCli() {
295+
try {
296+
const args = parseTraceToOtlpArgs(process.argv.slice(2));
297+
const input = JSON.parse(readFileSync(args.input, 'utf8'));
298+
const output = `${JSON.stringify(
299+
traceExportToOtlp(input, { includeContent: args.includeContent }),
300+
null,
301+
2,
302+
)}\n`;
303+
if (args.output) {
304+
writeFileSync(args.output, output);
305+
} else {
306+
process.stdout.write(output);
307+
}
308+
} catch (error) {
309+
console.error(`trace-to-otlp: ${error.message}`);
310+
process.exitCode = 1;
311+
}
312+
}
313+
314+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
315+
runCli();
316+
}

0 commit comments

Comments
 (0)