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
20 changes: 19 additions & 1 deletion src/trace/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,25 @@ export class FileSystemTraceStore implements TraceStore {
await store.updateRun(record.runId, record)
}
} else if (base === 'spans') {
await store.appendSpan(record)
// `updateSpan` appends an `_update: true` patch row instead of
// rewriting the original span. On reload we must collapse those
// patches into the prior span — otherwise a fresh
// FileSystemTraceStore reading the same dir reports duplicate
// spans (one full, one fragment with no runId/kind/name), which
// breaks any downstream consumer that re-opens the store
// cross-process (e.g. the canonical eval's OTLP converter).
if (record?._update) {
try {
await store.updateSpan(record.spanId, record)
} catch {
// Patch row arrived before the original — should not happen
// with locked append order, but fall through to append so we
// don't lose data.
await store.appendSpan(record)
}
} else {
await store.appendSpan(record)
}
} else if (base === 'events') {
await store.appendEvent(record)
} else if (base === 'artifacts') {
Expand Down
31 changes: 31 additions & 0 deletions tests/trace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,37 @@ describe('FileSystemTraceStore', () => {
expect(spans).toHaveLength(1)
expect(spans[0].endedAt).toBe(2)
})

it('cross-process reload merges _update span patches into the original span — regression: a fresh FileSystemTraceStore opened on a written dir reported each span twice (the original row + the patch fragment with no runId/kind/name), which broke OTLP conversion downstream (canonical eval analyst saw 0 spans)', async () => {
const dir = await makeDir()

// First process: write run, append a full LLM span, then patch it via updateSpan.
const writer = new FileSystemTraceStore({ dir })
await writer.appendRun(makeRun('r1'))
await writer.appendSpan({
runId: 'r1',
spanId: 's1',
kind: 'llm',
model: 'claude',
messages: [{ role: 'user', content: 'hi' }],
name: 'turn-1',
startedAt: 1,
})
await writer.updateSpan('s1', { endedAt: 5, status: 'ok', output: 'merged-output' } as Partial<Span>)

// Second process: fresh store reads the dir from scratch (forces load()).
const reader = new FileSystemTraceStore({ dir })
const spans = await reader.spans({ runId: 'r1' })
expect(spans).toHaveLength(1)
const [s] = spans
expect(s.spanId).toBe('s1')
expect(s.runId).toBe('r1') // must survive the merge (patch row has no runId)
expect(s.kind).toBe('llm') // must survive the merge (patch row has no kind)
expect(s.name).toBe('turn-1') // must survive the merge (patch row has no name)
expect(s.endedAt).toBe(5) // applied from patch
expect(s.status).toBe('ok') // applied from patch
expect((s as { output?: string }).output).toBe('merged-output')
})
})

describe('TraceEmitter', () => {
Expand Down
Loading