Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/timeline-export.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions plugins/timeline/src/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
},
Expand Down
15 changes: 15 additions & 0 deletions plugins/timeline/test/otel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Loading