diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 618a998c3..e3e8b5ef6 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -47,6 +47,14 @@ export interface TraceSpan { endTime?: number status: "ok" | "error" statusMessage?: string + /** + * True when this span was force-closed by trace reconstruction (worker + * restart / cache eviction) rather than by a real error. The span keeps + * `status: "error"` so its boundary stays visible, but consumers (the viewer, + * error aggregations) should treat it as "incomplete (reconstructed)" — not a + * genuine agent/tool failure. + */ + interrupted?: boolean // --- LLM / generation fields (populated for kind=generation) --- model?: { @@ -349,6 +357,62 @@ function formatDurationShort(ms: number): string { // Exported so the viewer's chat-tab dedupe can compare against the same boundary // (otherwise it'd silently drift if either side changes the magic number). export const USER_MESSAGE_INPUT_MAX_CHARS = 4000 + +/** + * Upper bound on the number of spans serialized into a single `ses_.json`. + * `snapshot()` rewrites the entire spans array on every event, so an unbounded + * long-lived session would grow the file without limit and pay O(n) per write + * (O(n²) over the session). When a trace exceeds this, serialization keeps the + * head (early context: prompt + first tools) and the tail (most recent + * activity) and elides the middle with a single marker span — bounding both + * file size and per-event write cost. In-memory spans are untouched; only the + * on-disk projection is capped. Override with `ALTIMATE_TRACE_MAX_SPANS`. + */ +export const MAX_SERIALIZED_SPANS = (() => { + const raw = parseInt(process.env["ALTIMATE_TRACE_MAX_SPANS"] ?? "", 10) + return Number.isFinite(raw) && raw > 0 ? raw : 5000 +})() + +/** + * Bound the spans written to disk to `cap` while preserving the most useful + * context: keep the head (root span, prompt, first tool calls) and the tail + * (most recent activity), and replace the elided middle with one marker span. + * Returns the input unchanged when it's already within the cap. + */ +export function capSpansForSerialization(spans: TraceSpan[], cap: number = MAX_SERIALIZED_SPANS): TraceSpan[] { + if (cap <= 0 || spans.length <= cap) return spans + const headCount = Math.max(1, Math.floor(cap * 0.3)) + const tailCount = Math.max(1, cap - headCount - 1) // reserve one slot for the marker + // Only elide if the result is actually smaller than the input (+1 for the + // marker we'd add) — otherwise there's nothing to gain. + if (headCount + tailCount + 1 >= spans.length) return spans + let head = spans.slice(0, headCount) + const tail = spans.slice(spans.length - tailCount) + // Guarantee the structural root (session) span survives the cut even if it + // isn't in the head slice — rehydrate and the viewer's tree both require it, + // and the elision marker is parented to it. In practice the root is index 0 + // (pushed first), so this is defensive, but it makes the invariant explicit + // instead of silently depending on span ordering. + const rootSpan = spans.find((s) => s.parentSpanId === null) ?? null + if (rootSpan && !head.some((s) => s.spanId === rootSpan.spanId) && !tail.some((s) => s.spanId === rootSpan.spanId)) { + head = [rootSpan, ...head.slice(0, headCount - 1)] + } + const elided = spans.length - head.length - tail.length + const rootId = rootSpan?.spanId ?? null + const anchor = head[head.length - 1] + const anchorTime = anchor?.endTime ?? anchor?.startTime ?? 0 + const marker: TraceSpan = { + spanId: `elided-${head.length}-${tail.length}-of-${spans.length}`, + parentSpanId: rootId, + name: `… ${elided} spans elided (trace exceeded ${cap} spans) …`, + kind: "span", + startTime: anchorTime, + endTime: anchorTime, + status: "ok", + attributes: { elided, totalSpans: spans.length }, + } + return [...head, marker, ...tail] +} // altimate_change end export class Trace { @@ -592,6 +656,9 @@ export class Trace { s.endTime = now s.status = "error" s.statusMessage = "interrupted — altimate-code restarted before this step finished recording; not an agent failure" + // Distinguish a recorder restart from a real failure so the viewer and + // error aggregations don't paint this red or count it as an incident. + s.interrupted = true } } this.endTraceStarted = false @@ -927,6 +994,8 @@ export class Trace { snapshotMetadata = this.metadata } + snapshotSpans = capSpansForSerialization(snapshotSpans) + return { version: 2, traceId: this.traceId, diff --git a/packages/opencode/src/altimate/observability/viewer.ts b/packages/opencode/src/altimate/observability/viewer.ts index df80d46a6..8fc6ab256 100644 --- a/packages/opencode/src/altimate/observability/viewer.ts +++ b/packages/opencode/src/altimate/observability/viewer.ts @@ -196,6 +196,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Ar .wf-preview .pv-tag.model { background: rgba(77,142,255,0.12); color: var(--secondary); } .wf-preview .pv-tag.tok { background: rgba(74,222,128,0.12); color: var(--green); } .wf-preview .pv-tag.err { background: rgba(248,113,113,0.12); color: var(--red); } +.wf-preview .pv-tag.warn { background: rgba(251,191,36,0.12); color: var(--orange); } .wf-bar-c { flex: 1; height: 18px; position: relative; overflow: hidden; } .wf-bar { position: absolute; height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.85; display: flex; align-items: center; padding-left: 4px; } .wf-bar.generation { background: var(--secondary); } @@ -222,6 +223,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Ar .tree-preview .pv-tag.model { background: rgba(77,142,255,0.12); color: var(--secondary); } .tree-preview .pv-tag.tok { background: rgba(74,222,128,0.12); color: var(--green); } .tree-preview .pv-tag.err { background: rgba(248,113,113,0.12); color: var(--red); } +.tree-preview .pv-tag.warn { background: rgba(251,191,36,0.12); color: var(--orange); } .tree-detail { margin-top: 8px; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; display: none; } .tree-detail.open { display: block; } @@ -443,7 +445,9 @@ var icons = { session: '\\u25A0', generation: '\\u2B50', tool: '\\u2692', text: function getPreview(span) { var parts = []; if (span.status === 'error' && span.statusMessage) { - return '\\u2718' + e((span.statusMessage || '').slice(0, 120)); + // Interrupted = recorder restart, not a real failure: amber warn, not red. + var tag = span.interrupted ? '\\u26A0' : '\\u2718'; + return tag + e((span.statusMessage || '').slice(0, 120)); } if (span.kind === 'tool') { var inp = span.input; @@ -467,7 +471,7 @@ function getPreview(span) { } } } - if (span.status === 'error') parts.unshift('\\u2718'); + if (span.status === 'error') parts.unshift(span.interrupted ? '\\u26A0' : '\\u2718'); } else if (span.kind === 'generation') { if (span.model && span.model.modelId) parts.push('' + e(span.model.modelId) + ''); if (span.tokens && span.tokens.total) parts.push('' + Number(span.tokens.total).toLocaleString() + ' tok'); @@ -487,8 +491,9 @@ function showDetail(span) { var dur = (span.endTime || Date.now()) - (span.startTime || 0); var h = '

' + e(span.name) + '

'; h += '
Kind
' + e(span.kind||'') + '
'; - h += '
Status
' + e(span.status||'') + ''; - if (span.statusMessage) h += '
Error
' + e(span.statusMessage) + '
'; + var statusColor = span.interrupted ? 'var(--orange)' : (span.status==='error' ? 'var(--red)' : ''); + h += '
Status
' + e(span.interrupted ? 'interrupted' : (span.status||'')) + ''; + if (span.statusMessage) h += '
' + (span.interrupted ? 'Interrupted' : 'Error') + '
' + e(span.statusMessage) + '
'; h += '
Duration
' + fd(dur) + '
'; if (span.model) { if (span.model.modelId) h += '
Model
' + e(span.model.modelId) + '
'; @@ -565,7 +570,10 @@ function showDetail(span) { // --- Classify all tool spans upfront --- var toolSpans = nonSession.filter(function(sp) { return sp.kind === 'tool'; }); var genSpans = nonSession.filter(function(sp) { return sp.kind === 'generation'; }); - var errSpans = nonSession.filter(function(sp) { return sp.status === 'error'; }); + // Reconstructed (interrupted) spans keep status:'error' for boundary + // visibility, but they reflect a recorder restart — exclude them from the + // session error count so a clean session isn't reported as failed. + var errSpans = nonSession.filter(function(sp) { return sp.status === 'error' && !sp.interrupted; }); // Categorize files: changed (edit/write) vs read var changedFiles = {}; @@ -1239,12 +1247,12 @@ function showDetail(span) { var dur = (span.endTime || Date.now()) - (span.startTime||0); var left = (st / tTotal * 100).toFixed(2); var width = Math.max(0.5, dur / tTotal * 100).toFixed(2); - var cls = span.status === 'error' ? 'error' : e(span.kind); + var cls = (span.status === 'error' && !span.interrupted) ? 'error' : e(span.kind); var row = document.createElement('div'); row.className = 'wf-row'; row.setAttribute('data-idx', String(idx)); if (span.spanId) row.setAttribute('data-span-id', span.spanId); - var iconCls = span.status === 'error' ? 'error' : e(span.kind); + var iconCls = (span.status === 'error' && !span.interrupted) ? 'error' : e(span.kind); var pv = getPreview(span); row.innerHTML = '
' + (icons[span.kind]||'\\u2022') + '
' + '
' + e(span.name) + '
' + (pv ? '
' + pv + '
' : '') + '
' + @@ -1278,7 +1286,8 @@ function showDetail(span) { meta.push(fd(dur)); if (span.tokens) meta.push(Number(span.tokens.total||0) + ' tok'); if (span.cost) meta.push(fc(span.cost)); - if (span.status === 'error') meta.push('error'); + if (span.interrupted) meta.push('interrupted'); + else if (span.status === 'error') meta.push('error'); html += '
'; html += '
'; html += '' + e(span.kind) + ''; @@ -1390,7 +1399,7 @@ function showDetail(span) { if (span.kind === 'session') return; var idx = spans.indexOf(span); var ts = span.startTime ? new Date(span.startTime).toISOString().slice(11,23) : ''; - var kindCls = span.status === 'error' ? 'error' : e(span.kind); + var kindCls = (span.status === 'error' && !span.interrupted) ? 'error' : e(span.kind); html += '
'; html += '' + ts + ''; var logIcon = span.kind === 'generation' ? '\\u2B50' : span.kind === 'tool' ? '\\u2692' : '\\u25A0'; @@ -1400,7 +1409,8 @@ function showDetail(span) { if (span.tokens) html += ' ' + Number(span.tokens.total||0) + ' tok'; if (span.cost) html += ' ' + fc(span.cost) + ''; if (span.tool && span.tool.durationMs != null) html += ' ' + fd(span.tool.durationMs) + ''; - if (span.status === 'error') html += ' \\u2718 ' + e((span.statusMessage||'').slice(0,100)) + ''; + if (span.interrupted) html += ' \\u26A0 ' + e((span.statusMessage||'').slice(0,100)) + ''; + else if (span.status === 'error') html += ' \\u2718 ' + e((span.statusMessage||'').slice(0,100)) + ''; if (span.kind === 'tool' && span.input) { var logPv = getPreview(span); if (logPv) html += '
' + logPv + '
'; diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 777abc8ba..6c2829c8c 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -47,6 +47,14 @@ const eventStream = { abort: undefined as AbortController | undefined, } +// altimate_change start — trace: monotonic stream generation. Bumped on every +// startEventStream() so an in-flight getOrCreateTrace() can detect that its +// owning stream was torn down while it was suspended at an await. Keyed on a +// counter rather than the AbortController's object identity so the guard does +// not silently depend on startEventStream always allocating a fresh controller. +let streamGeneration = 0 +// altimate_change end + // altimate_change start — trace: per-session traces const sessionTraces = new Map() const sessionUserMsgIds = new Map>() // Per-session user message IDs (cleaned up on session end) @@ -83,6 +91,13 @@ async function loadTracingConfig() { async function getOrCreateTrace(sessionID: string): Promise { if (!sessionID || !tracingEnabled) return null if (sessionTraces.has(sessionID)) return sessionTraces.get(sessionID)! + // altimate_change start — capture the stream generation that owns this call so + // we can detect a concurrent startEventStream() (e.g. setWorkspace) that + // aborted us and cleared the cache while we were suspended at the rehydrate + // await below. A counter (not AbortController identity) so we don't depend on + // startEventStream's allocation strategy. + const generationAtEntry = streamGeneration + // altimate_change end try { if (sessionTraces.size >= MAX_TRACES) { const oldest = sessionTraces.keys().next().value @@ -106,9 +121,21 @@ async function getOrCreateTrace(sessionID: string): Promise { trace.startTrace(sessionID, {}) } // altimate_change end + // altimate_change start — if a new stream replaced ours while we were + // awaiting rehydrate, this Trace belongs to a stream that's already been + // aborted and its cache cleared. Inserting it now would resurrect an orphan + // writer into the freshly-cleared map. Discard it and defer to whatever the + // live stream has. The check and the set below run in the same synchronous + // turn (no await between them), so the insert can't race a later + // startEventStream — this closes the suspend-at-await hole specifically. + if (streamGeneration !== generationAtEntry) { + void trace.endTrace().catch(() => {}) + return sessionTraces.get(sessionID) ?? null + } Trace.setActive(trace) sessionTraces.set(sessionID, trace) return trace + // altimate_change end } catch { return null } @@ -117,6 +144,10 @@ async function getOrCreateTrace(sessionID: string): Promise { const startEventStream = (input: { directory: string; workspaceID?: string }) => { if (eventStream.abort) eventStream.abort.abort() + // altimate_change start — new stream generation; invalidates any in-flight + // getOrCreateTrace() suspended at its rehydrate await (see generationAtEntry). + streamGeneration++ + // altimate_change end // Clear stale per-stream trace state before starting a new stream instance for (const [, trace] of sessionTraces) { void trace.endTrace().catch(() => {}) diff --git a/packages/opencode/test/altimate/tracing-followups.test.ts b/packages/opencode/test/altimate/tracing-followups.test.ts new file mode 100644 index 000000000..a0803cecb --- /dev/null +++ b/packages/opencode/test/altimate/tracing-followups.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for the v0.8.4 tracing-reliability follow-ups: + * - #901: reconstructed (interrupted) spans are distinguishable from real + * errors — `interrupted: true` on the span, amber (not red) in the + * viewer, excluded from the session error count. + * - #903: `capSpansForSerialization` bounds the on-disk spans for long-lived + * sessions (head + tail retention + an elision marker). + */ + +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { + Trace, + FileExporter, + capSpansForSerialization, + type TraceFile, + type TraceSpan, +} from "../../src/altimate/observability/tracing" +import { renderTraceViewer } from "../../src/altimate/observability/viewer" +import { tmpdir } from "../fixture/fixture" + +function makeTrace(dir: string) { + return Trace.withExporters([new FileExporter(dir)]) +} + +async function readTraceFile(dir: string, sessionId: string): Promise { + const safeId = sessionId.replace(/[/\\.:]/g, "_") + return JSON.parse(await fs.readFile(path.join(dir, `${safeId}.json`), "utf-8")) +} + +function span(i: number, over: Partial = {}): TraceSpan { + return { spanId: `s${i}`, parentSpanId: "root", name: `span-${i}`, kind: "tool", startTime: i, endTime: i, status: "ok", ...over } +} + +describe("#901 — interrupted spans are marked and not counted as errors", () => { + test("rehydrate sets interrupted:true (not just status:error) on in-flight generation spans", async () => { + await using tmp = await tmpdir() + const id = "ses_interrupted_flag" + + const original = makeTrace(tmp.path) + original.startTrace(id, {}) + original.logStepStart({ id: "step-1" } as any) // opens a generation span + original.logToolCall({ tool: "read", state: { status: "completed", input: { f: "a" } } } as any) // snapshot + await original.flush() + const before = await readTraceFile(tmp.path, id) + const openGen = before.spans.find((s) => s.kind === "generation") + expect(openGen?.endTime).toBeUndefined() + + const reconstructed = makeTrace(tmp.path) + expect(await reconstructed.rehydrateFromFile(id)).toBe(true) + reconstructed.logToolCall({ tool: "read", state: { status: "completed", input: { f: "b" } } } as any) + await reconstructed.flush() + + const after = await readTraceFile(tmp.path, id) + const gen = after.spans.find((s) => s.spanId === openGen?.spanId) + expect(gen?.status).toBe("error") // boundary still visible + expect(gen?.interrupted).toBe(true) // but flagged as a reconstruction, not a failure + }) + + test("viewer renders an amber 'warn' affordance and excludes interrupted spans from the error count", async () => { + // The viewer's per-span rendering and error counting run in embedded client + // JS; assert the rendered document carries the interrupted-aware contract. + const trace: TraceFile = { + version: 2, + traceId: "tr", + sessionId: "ses_v", + startedAt: new Date(0).toISOString(), + metadata: {}, + spans: [ + { spanId: "root", parentSpanId: null, name: "ses_v", kind: "session", startTime: 0, status: "ok" }, + { spanId: "g", parentSpanId: "root", name: "gen", kind: "generation", startTime: 1, endTime: 2, status: "error", statusMessage: "interrupted — restarted", interrupted: true }, + ], + summary: { + totalTokens: 0, totalCost: 0, totalToolCalls: 0, totalGenerations: 1, duration: 2, + status: "completed", tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + } as any, + } + const html = renderTraceViewer(trace) + // amber tag style exists + expect(html).toContain("pv-tag warn") + // error count excludes interrupted spans + expect(html).toContain("!sp.interrupted") + // the interrupted span's flag survives into the embedded data + expect(html).toContain('"interrupted":true') + // the secondary surfaces (waterfall row class, tree meta, log row) all + // gate the red/error treatment on !span.interrupted so reconstructed spans + // render amber, not red, everywhere — not just in the preview/detail. + expect(html).toContain("span.status === 'error' && !span.interrupted") + expect(html).toContain("color:var(--orange)\">interrupted") + }) +}) + +describe("#903 — capSpansForSerialization bounds long-lived traces", () => { + test("returns the array unchanged when within the cap", () => { + const spans = [span(0, { spanId: "root", parentSpanId: null, kind: "session" }), span(1), span(2)] + expect(capSpansForSerialization(spans, 10)).toBe(spans) + }) + + test("caps to the limit, keeps head + tail, inserts one elision marker", () => { + const spans: TraceSpan[] = [{ spanId: "root", parentSpanId: null, name: "root", kind: "session", startTime: 0, status: "ok" }] + for (let i = 1; i < 100; i++) spans.push(span(i)) + const cap = 10 + const out = capSpansForSerialization(spans, cap) + + expect(out.length).toBeLessThanOrEqual(cap) + // root (head) preserved + expect(out[0].spanId).toBe("root") + // most-recent span (tail) preserved + expect(out[out.length - 1].spanId).toBe("s99") + // exactly one elision marker, with an accurate count + const markers = out.filter((s) => s.name.includes("elided")) + expect(markers).toHaveLength(1) + const keptReal = out.length - 1 + expect((markers[0].attributes as any).elided).toBe(spans.length - keptReal) + expect((markers[0].attributes as any).totalSpans).toBe(100) + // marker is parented to the real root so the viewer can place it + expect(markers[0].parentSpanId).toBe("root") + }) + + test("does not elide when the cap is too small to gain anything", () => { + const spans = [span(0), span(1), span(2)] + // cap 2 → head 1, tail 1, marker would make 3 ≥ original 3: no benefit, keep as-is + expect(capSpansForSerialization(spans, 2)).toBe(spans) + }) + + test("buildTraceFile applies the cap (wired into serialization)", async () => { + // Behavioral: drive a real Trace past a tiny cap and confirm the on-disk + // file is bounded. We exercise the cap function the serializer calls. + const spans: TraceSpan[] = [{ spanId: "root", parentSpanId: null, name: "root", kind: "session", startTime: 0, status: "ok" }] + for (let i = 1; i < 50; i++) spans.push(span(i)) + const capped = capSpansForSerialization(spans, 12) + expect(capped.length).toBeLessThanOrEqual(12) + // round-trips through JSON like the snapshot writer does + const roundTripped = JSON.parse(JSON.stringify(capped)) as TraceSpan[] + expect(roundTripped.find((s) => s.parentSpanId === null)).toBeDefined() + }) +}) + +describe("#902 — getOrCreateTrace guards against resurrecting a Trace into a cleared cache", () => { + // worker.ts has module-scope side effects (it starts an event stream on + // import), so it can't be unit-tested in-process. Lock the guard's shape with + // a scope-bounded source contract, the same approach as + // worker-trace-clearing.test.ts. + test("captures the stream generation before the rehydrate await and re-checks it after", async () => { + const workerSrc = await fs.readFile( + path.join(__dirname, "../../src/cli/cmd/tui/worker.ts"), + "utf-8", + ) + + // Ownership is keyed on a monotonic counter, not AbortController identity. + expect(workerSrc).toMatch(/let streamGeneration = 0/) + // The owning generation is captured at entry, before any await. + expect(workerSrc).toMatch(/const generationAtEntry = streamGeneration/) + // A new stream bumps the counter, invalidating in-flight calls. + expect(workerSrc).toMatch(/streamGeneration\+\+/) + + // After awaiting rehydrate, if a new stream replaced ours, the freshly built + // Trace is discarded (ended) instead of being inserted into the cleared map. + const guard = workerSrc.match( + /if \(streamGeneration !== generationAtEntry\)[\s\S]*?trace\.endTrace\(\)[\s\S]*?return sessionTraces\.get\(sessionID\) \?\? null/, + ) + expect(guard).not.toBeNull() + + // The guard must sit AFTER the rehydrate await and BEFORE the cache insert, + // otherwise it can't prevent the orphan write. + const awaitIdx = workerSrc.indexOf("await trace.rehydrateFromFile(sessionID)") + const guardIdx = workerSrc.indexOf("if (streamGeneration !== generationAtEntry)") + const setIdx = workerSrc.indexOf("sessionTraces.set(sessionID, trace)") + expect(awaitIdx).toBeGreaterThan(-1) + expect(guardIdx).toBeGreaterThan(awaitIdx) + expect(setIdx).toBeGreaterThan(guardIdx) + + // The bump happens inside startEventStream so a workspace switch invalidates + // any suspended getOrCreateTrace. + const startIdx = workerSrc.indexOf("const startEventStream =") + const bumpIdx = workerSrc.indexOf("streamGeneration++") + expect(bumpIdx).toBeGreaterThan(startIdx) + }) +}) + +describe("#903 — capSpansForSerialization structural guarantees (review hardening)", () => { + test("always retains the root span even when it is NOT at index 0", () => { + // Root deliberately placed in the middle so the naive head-slice would drop it. + const spans: TraceSpan[] = [] + for (let i = 0; i < 40; i++) spans.push(span(i)) + const root: TraceSpan = { spanId: "ROOT", parentSpanId: null, name: "root", kind: "session", startTime: 20, status: "ok" } + spans.splice(20, 0, root) // root now at index 20, not in the head slice + const out = capSpansForSerialization(spans, 10) + expect(out.some((s) => s.spanId === "ROOT")).toBe(true) + // the elision marker is parented to the real root + const marker = out.find((s) => s.name.includes("elided")) + expect(marker?.parentSpanId).toBe("ROOT") + expect(out.length).toBeLessThanOrEqual(10) + }) + + test.each([1, 2, 3, 4, 5])("tiny cap=%i never throws and never references a missing parent", (cap) => { + const spans: TraceSpan[] = [{ spanId: "root", parentSpanId: null, name: "root", kind: "session", startTime: 0, status: "ok" }] + for (let i = 1; i < 30; i++) spans.push(span(i)) + const out = capSpansForSerialization(spans, cap) + // every non-root span's parent (if it's a kept span's parent) must resolve, + // OR be the root, OR be absent (orphan attaches to root in the viewer). + const ids = new Set(out.map((s) => s.spanId)) + const marker = out.find((s) => s.name.includes("elided")) + if (marker) { + expect(marker.parentSpanId === null || ids.has(marker.parentSpanId as string)).toBe(true) + } + // a root span is always present in the output + expect(out.some((s) => s.parentSpanId === null)).toBe(true) + }) + + test("exact boundary head+tail+1 === length returns the original (no pointless marker)", () => { + // cap=10 → head 3, tail 6, +1 marker = 10. length 10 → 10 >= 10 → pass-through. + const spans: TraceSpan[] = [{ spanId: "root", parentSpanId: null, name: "root", kind: "session", startTime: 0, status: "ok" }] + for (let i = 1; i < 10; i++) spans.push(span(i)) + expect(spans.length).toBe(10) + expect(capSpansForSerialization(spans, 10)).toBe(spans) + }) + + test("elision marker reports an accurate elided count", () => { + const spans: TraceSpan[] = [{ spanId: "root", parentSpanId: null, name: "root", kind: "session", startTime: 0, status: "ok" }] + for (let i = 1; i < 1000; i++) spans.push(span(i)) + const cap = 100 + const out = capSpansForSerialization(spans, cap) + const marker = out.find((s) => s.name.includes("elided"))! + const keptReal = out.length - 1 // minus the marker + expect((marker.attributes as any).elided).toBe(spans.length - keptReal) + expect((marker.attributes as any).totalSpans).toBe(1000) + }) +})