diff --git a/changelog.d/timeline-export.added.md b/changelog.d/timeline-export.added.md new file mode 100644 index 000000000..7ff7ab13c --- /dev/null +++ b/changelog.d/timeline-export.added.md @@ -0,0 +1 @@ +The Timeline plugin can now be asked for an export on demand. `timeline/export` returns the current trace as one OpenTelemetry OTLP/HTTP JSON payload — the same mapping the timer exporter uses — so a tool or the panel can pull it now instead of waiting for the next flush. It is read-only, sends Bot and room ids only unless the caller sets `names: true`, and needs no configuration. diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index 9605f65ba..ff3d528cf 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -35,6 +35,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model - `plugins/timeline/index.js` — loaded as `@flybot/timeline`. - Endpoint `timeline/window` (wire name `flybot/timeline/window`), registered `{ read: true }` so a read-only host still answers. Body `{ windowMs?, to? }`; window 30 s to 24 h, default 15 min. +- Endpoint `timeline/export` (#357, read-only): the current trace mapped to one OTLP/HTTP JSON payload (`resourceSpans`), the same mapping the timer exporter uses. Ids only unless the body sets `names: true` (#483); `serviceName` overrides the default. The panel or an automation can pull it on demand instead of waiting for the next flush. ## Invariants diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 2e2508b00..35cb2fe84 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -2,9 +2,10 @@ import { createRecorder } from './recorder.js' import { openTrace, TraceLocked } from './store.js' import { buildWindow, clampWindow } from './window.js' -import { createExporter } from './otel.js' +import { createExporter, otlpPayload, spansFrom } from './otel.js' export const ENDPOINT = 'timeline/window' +export const EXPORT_ENDPOINT = 'timeline/export' export function createTimeline(adapter, { dir, persist = true, now = Date.now, exporter, ...limits } = {}) { let trace @@ -43,11 +44,17 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e stats: { ...trace.stats(), persisting }, }) const offEndpoint = adapter.handle(ENDPOINT, window) + // On-demand OTLP export (#357): the same mapping the timer exporter uses, built from the trace + // as it stands. Ids only unless the caller asks for names (#483), so a one-off export never + // sends another home's names by accident. + const exportWindow = (body = {}) => otlpPayload(spansFrom(trace.records(), { names: body?.names === true ? trace.names() : {} }), { serviceName: body?.serviceName }) + const offExport = adapter.handle(EXPORT_ENDPOINT, exportWindow) return { - trace, recorder, window, exportNow, + trace, recorder, window, exportNow, exportWindow, dispose() { if (exportTimer !== undefined) clearInterval(exportTimer) offEndpoint() + offExport() off() trace.close() }, diff --git a/plugins/timeline/test/otel.test.js b/plugins/timeline/test/otel.test.js index 7f474497b..8f5a76235 100644 --- a/plugins/timeline/test/otel.test.js +++ b/plugins/timeline/test/otel.test.js @@ -110,3 +110,18 @@ test('the plugin timeline exports through the configured send and nothing else c assert.deepEqual(spans.map(span => span.name).sort(), ['execute_tool web_search', 'invoke_agent']) timeline.dispose() }) + +test('the on-demand export (#357) builds spans from the current trace, ids only unless asked', () => { + const adapter = mockAdapter() + const timeline = createTimeline(adapter, { persist: false, now: () => 5_000 }) + adapter.feed('turn.start', { botId: 'a', place: 'chat', sessionId: 's1', turnId: 's1#1', turn: 1, time: 4_100, trigger: { exchangeId: 'x1', from: { kind: 'user' }, role: 'user', hop: 0 } }) + adapter.feed('turn.end', { botId: 'a', place: 'chat', sessionId: 's1', turnId: 's1#1', turn: 1, time: 4_900, outcome: 'completed' }) + const spans = timeline.exportWindow().resourceSpans[0].scopeSpans[0].spans + assert.equal(spans.length, 1) + const attrs = Object.fromEntries(spans[0].attributes.map(a => [a.key, a.value])) + assert.equal(attrs['gen_ai.agent.id'].stringValue, 'a') + assert.equal(attrs['gen_ai.agent.name'], undefined, 'ids only by default') + const named = timeline.exportWindow({ names: true }).resourceSpans[0].scopeSpans[0].spans + assert.equal(Object.fromEntries(named[0].attributes.map(a => [a.key, a.value]))['gen_ai.agent.name'].stringValue, 'Chief') + timeline.dispose() +})