diff --git a/changelog.d/2442-timeline-export-untimed-records.fixed.md b/changelog.d/2442-timeline-export-untimed-records.fixed.md new file mode 100644 index 000000000..5962585bc --- /dev/null +++ b/changelog.d/2442-timeline-export-untimed-records.fixed.md @@ -0,0 +1 @@ +The timeline's on-demand OpenTelemetry export now skips a trace record that carries no usable timestamp instead of throwing `RangeError: The number NaN cannot be converted to a BigInt`, so one stale or hand-edited record no longer costs the export of every complete turn beside it ([#2442](https://github.com/ThinkFlowLab/FlyBot/issues/2442)). diff --git a/plugins/timeline/src/otel.js b/plugins/timeline/src/otel.js index 81485c7ca..09e85a32a 100644 --- a/plugins/timeline/src/otel.js +++ b/plugins/timeline/src/otel.js @@ -42,6 +42,10 @@ const partyAttrs = (prefix, party) => { * Records -> OTLP span objects. Only CLOSED spans are built: a turn needs its `te` and a tool * call its `ke`. An open turn is never exported as a finished one — the exporter carries it * with `pendingRecords` until its end arrives. Deliveries are instant spans. + * + * A record whose `t` is not a finite number is skipped: a segment can come from an older version + * or a hand edit, and `fold` in src/window.js keeps the same rule. Without it `nanos` would turn + * the missing time into `BigInt(NaN)` and one stale record would throw the whole export. */ export function spansFrom(records, { names = {} } = {}) { const starts = new Map() @@ -49,7 +53,7 @@ export function spansFrom(records, { names = {} } = {}) { const toolStarts = new Map() const toolEnds = new Map() for (const record of records) { - if (!record || typeof record !== 'object') continue + if (!record || typeof record !== 'object' || !Number.isFinite(record.t)) continue if (record.r === 'ts') starts.set(record.id, record) else if (record.r === 'te') ends.set(record.id, record) else if (record.r === 'ks') toolStarts.set(record.id, record) diff --git a/plugins/timeline/test/otel-untimed-records.test.js b/plugins/timeline/test/otel-untimed-records.test.js new file mode 100644 index 000000000..f2a3961e3 --- /dev/null +++ b/plugins/timeline/test/otel-untimed-records.test.js @@ -0,0 +1,48 @@ +// A trace segment outlives the version that wrote it: the store keeps every record whose `t` is +// not provably older than the retention window, and `fold` in src/window.js checks +// `Number.isFinite(record.t)` before it trusts one. The OTLP mapping reads the same records, so it +// has to keep the same rule: an untimed turn or tool call is skipped, not converted to a span. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { spansFrom } from '../src/otel.js' +import { createTimeline } from '../src/timeline.js' +import { mockAdapter } from './mock-adapter.js' + +// The shape a segment written by an earlier version (or edited by hand) can carry: an ISO string +// for the end, nothing at all for the start. +const untimed = [ + { r: 'ts', id: 'old#1', pl: 'chat', bot: 'a' }, + { r: 'te', id: 'old#1', t: '2026-11-04T10:00:00.000Z', o: 'completed' }, +] +const timed = [ + { r: 'ts', t: 1_000, id: 'new#1', pl: 'chat', bot: 'a' }, + { r: 'te', t: 1_600, id: 'new#1', o: 'completed' }, +] + +test('a turn or tool record with no usable time is skipped, never converted to a span', () => { + assert.deepEqual(spansFrom(untimed), []) + assert.deepEqual(spansFrom([ + { r: 'ks', t: 1_100, id: 'c1', tid: 'new#1', nm: 'web_search', bot: 'a' }, + { r: 'ke', t: '2026-11-04T10:00:01.000Z', id: 'c1', tid: 'new#1', ok: true }, + ]), []) + const spans = spansFrom([...untimed, ...timed]) + assert.deepEqual(spans.map(span => span.name), ['invoke_agent']) + assert.equal(spans[0].startTimeUnixNano, '1000000000') +}) + +test('timeline/export still answers OTLP JSON when the trace holds an untimed record', () => { + 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 }) + adapter.feed('turn.end', { botId: 'a', place: 'chat', sessionId: 's1', turnId: 's1#1', turn: 1, time: 4_900, outcome: 'completed' }) + for (const record of untimed) timeline.trace.append(record) + // The store keeps the untimed records (their `t` cannot be proved older than the window); the + // export is the reader that has to cope with them. + const kept = timeline.trace.records() + assert.ok(kept.some(record => record.r === 'ts' && record.id === 'old#1')) + assert.ok(kept.some(record => record.r === 'te' && record.id === 'old#1')) + const spans = timeline.exportWindow().resourceSpans[0].scopeSpans[0].spans + assert.equal(spans.length, 1) + assert.equal(spans[0].name, 'invoke_agent') + timeline.dispose() +})