diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index 24f22ebc..61ea9914 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -35,6 +35,52 @@ function coalesceKey(entry: LogsEntry): string { ].join(' '); } +/** + * Coalesce identical consecutive children inside a single trace. + * + * Key tuple includes `message` so two children that share + * event/stage/level/module but report different content stay visible as + * separate rows. Includes `source` so adjacent rows from different + * services with otherwise-identical fields stay distinct (a request can + * fan out across multiple sources participating in the same trace). + * Excludes `latencyMs` — it drifts per request and would defeat the + * dedup on truly redundant polls. + * + * Exported so it can be unit-tested independently from the React row. + */ +export function coalesceChildren(children: LogsEntry[]): Array<{ head: LogsEntry; count: number }> { + if (children.length === 0) return []; + const out: Array<{ head: LogsEntry; count: number }> = []; + let head: LogsEntry | null = null; + let count = 0; + let key = ''; + const flush = () => { + if (head) out.push({ head, count }); + head = null; + count = 0; + }; + for (const child of children) { + const k = [ + child.event ?? '', + child.message ?? '', + child.stage ?? '', + child.level, + child.module ?? '', + child.source ?? '', + ].join(' '); + if (head && k === key) { + count += 1; + } else { + flush(); + head = child; + count = 1; + key = k; + } + } + flush(); + return out; +} + /** * For an entry without an explicit `stage` field, derive a short chip label * from the event name so the trace timeline still renders meaningful badges diff --git a/ui/src/components/logs/logs-trace-row.tsx b/ui/src/components/logs/logs-trace-row.tsx index a9efd1aa..4b8d70f9 100644 Binary files a/ui/src/components/logs/logs-trace-row.tsx and b/ui/src/components/logs/logs-trace-row.tsx differ diff --git a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts new file mode 100644 index 00000000..03560ac5 --- /dev/null +++ b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import type { LogsEntry } from '@/lib/api-client'; +import { + coalesceChildren, + deriveStageHint, + deriveTraceGroups, + type LeafItem, +} from '@/components/logs/derive-trace-groups'; +import type { TraceGroup } from '@/components/logs/logs-trace-row'; + +// Minimal LogsEntry factory — fields the helpers actually look at. +function entry(overrides: Partial & Pick): LogsEntry { + return { + level: 'info', + source: 'web-server:http', + event: 'event.default', + message: 'default message', + ...overrides, + } as LogsEntry; +} + +function leafEntries( + ...overrides: Array & Pick> +) { + return overrides.map(entry); +} + +describe('deriveStageHint', () => { + it('returns explicit stage when present', () => { + expect(deriveStageHint(entry({ id: '1', timestamp: 't', stage: 'route' }))).toBe('route'); + }); + + it('falls back to last `.`-segment of event when stage is missing', () => { + expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'request.dispatched' }))).toBe( + 'dispatched' + ); + }); + + it('caps the derived hint at 12 chars', () => { + expect( + deriveStageHint(entry({ id: '1', timestamp: 't', event: 'foo.absurdlylongsegmentname' })) + ).toBe('absurdlylong'); + }); + + it('returns undefined when neither stage nor event is meaningful', () => { + expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: '' }))).toBeUndefined(); + }); + + it('treats single-segment event as its own hint', () => { + expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'startup' }))).toBe('startup'); + }); +}); + +describe('coalesceChildren', () => { + it('returns empty array for empty input', () => { + expect(coalesceChildren([])).toEqual([]); + }); + + it('passes through a single child as count=1', () => { + const e = entry({ id: '1', timestamp: 't', event: 'a.b' }); + expect(coalesceChildren([e])).toEqual([{ head: e, count: 1 }]); + }); + + it('coalesces two adjacent identical children', () => { + const a = entry({ id: '1', timestamp: 't1', event: 'a.b', message: 'm' }); + const b = entry({ id: '2', timestamp: 't2', event: 'a.b', message: 'm' }); + const result = coalesceChildren([a, b]); + expect(result).toHaveLength(1); + expect(result[0]?.count).toBe(2); + expect(result[0]?.head).toBe(a); + }); + + it('keeps children with different messages distinct (round-1 fix)', () => { + const a = entry({ id: '1', timestamp: 't1', event: 'auth.ok', message: 'alice' }); + const b = entry({ id: '2', timestamp: 't2', event: 'auth.ok', message: 'bob' }); + expect(coalesceChildren([a, b])).toHaveLength(2); + }); + + it('keeps children with different sources distinct (round-2 fix)', () => { + const a = entry({ id: '1', timestamp: 't1', source: 'svc-a', event: 'e' }); + const b = entry({ id: '2', timestamp: 't2', source: 'svc-b', event: 'e' }); + expect(coalesceChildren([a, b])).toHaveLength(2); + }); + + it('keeps children with different stages distinct', () => { + const a = entry({ id: '1', timestamp: 't1', stage: 'route', event: 'e' }); + const b = entry({ id: '2', timestamp: 't2', stage: 'dispatch', event: 'e' }); + expect(coalesceChildren([a, b])).toHaveLength(2); + }); + + it('only collapses adjacent duplicates — interleaving breaks the run', () => { + const a = entry({ id: '1', timestamp: 't1', event: 'a' }); + const b = entry({ id: '2', timestamp: 't2', event: 'b' }); + const c = entry({ id: '3', timestamp: 't3', event: 'a' }); + const result = coalesceChildren([a, b, c]); + // a, b, a — three distinct runs of count 1 each + expect(result.map((r) => r.count)).toEqual([1, 1, 1]); + }); +}); + +describe('deriveTraceGroups', () => { + it('returns empty array for empty input', () => { + expect(deriveTraceGroups([])).toEqual([]); + }); + + it('emits a single leaf for a no-requestId entry', () => { + const e = entry({ id: '1', timestamp: '2026-01-01T00:00:00Z' }); + const result = deriveTraceGroups([e]); + expect(result).toHaveLength(1); + expect(result[0]?.kind).toBe('leaf'); + expect((result[0] as LeafItem).entry).toBe(e); + expect((result[0] as LeafItem).repeatCount).toBeUndefined(); + }); + + it('groups entries sharing a requestId into one trace', () => { + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1', stage: 'intake' }, + { id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1', stage: 'route' } + ) + ); + expect(result).toHaveLength(1); + expect(result[0]?.kind).toBe('trace'); + expect((result[0] as TraceGroup).children).toHaveLength(2); + }); + + it('sorts trace children by ts ascending and pins group ts to oldest child', () => { + // Input order intentionally reverses ts so we can assert the sort. + const result = deriveTraceGroups( + leafEntries( + { id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1' }, + { id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1' } + ) + ); + const trace = result[0] as TraceGroup; + expect(trace.children.map((c) => c.id)).toEqual(['1', '2']); + expect(trace.ts).toBe('2026-01-01T00:00:01Z'); + }); + + it('coalesces two adjacent identical leaves into a single ×N row', () => { + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: 't1', event: 'poll', message: 'tick' }, + { id: '2', timestamp: 't2', event: 'poll', message: 'tick' } + ) + ); + expect(result).toHaveLength(1); + const leaf = result[0] as LeafItem; + expect(leaf.repeatCount).toBe(2); + expect(leaf.collapsedRange?.fromTs).toBe('t1'); + expect(leaf.collapsedRange?.toTs).toBe('t2'); + }); + + it('does NOT coalesce identical leaves split by an unrelated trace (round-3 fix)', () => { + // Two identical no-requestId entries with a trace between them: the run + // must break — they were not adjacent in the real stream. + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: 't1', event: 'poll', message: 'tick' }, + { id: '2', timestamp: 't2', requestId: 'req-1' }, + { id: '3', timestamp: 't3', event: 'poll', message: 'tick' } + ) + ); + // Expected: 1 trace + 2 distinct leaves (no ×N). + const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[]; + expect(leaves).toHaveLength(2); + expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true); + }); + + it('keeps adjacent leaves with different messages as separate rows', () => { + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: 't1', event: 'login', message: 'alice' }, + { id: '2', timestamp: 't2', event: 'login', message: 'bob' } + ) + ); + expect(result).toHaveLength(2); + expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); + }); + + it('display-sorts items reverse-chronologically', () => { + // Use distinct events so leaves don't coalesce — testing display sort, + // not coalesce. + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'a' }, + { id: '2', timestamp: '2026-01-01T00:00:03Z', event: 'b' }, + { id: '3', timestamp: '2026-01-01T00:00:02Z', event: 'c' } + ) + ); + const ids = result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace')); + expect(ids).toEqual(['2', '3', '1']); + }); + + it('computes max level across trace children', () => { + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: 't1', requestId: 'r', level: 'debug' }, + { id: '2', timestamp: 't2', requestId: 'r', level: 'error' }, + { id: '3', timestamp: 't3', requestId: 'r', level: 'info' } + ) + ); + expect((result[0] as TraceGroup).maxLevel).toBe('error'); + }); + + it('sums latencyMs across trace children', () => { + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: 't1', requestId: 'r', latencyMs: 100 }, + { id: '2', timestamp: 't2', requestId: 'r', latencyMs: 50 }, + { id: '3', timestamp: 't3', requestId: 'r' /* no latency */ } + ) + ); + expect((result[0] as TraceGroup).totalLatencyMs).toBe(150); + }); + + it('preserves original input order when computing leaf adjacency, even if display sort reorders later', () => { + // Stream: [A, B, A] — A entries identical but split by B. A run of 1+1+1. + const result = deriveTraceGroups( + leafEntries( + { id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'A' }, + { id: '2', timestamp: '2026-01-01T00:00:02Z', event: 'B' }, + { id: '3', timestamp: '2026-01-01T00:00:03Z', event: 'A' } + ) + ); + const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[]; + expect(leaves).toHaveLength(3); + // None should have repeatCount because A and A weren't adjacent. + expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true); + }); +});