From 6ada2b243e339067071fe1c00d41a57581c3e403 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 16:24:19 -0400 Subject: [PATCH] test(ui): cover trace-coalesce, leaf-coalesce, and stage-hint helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-Agent flagged "No relevant tests" each round. Add 23 unit tests covering the pure-function surface introduced by this PR: - deriveStageHint — explicit stage wins, fallback to last .-segment of event, 12-char cap, undefined when nothing meaningful. - coalesceChildren — empty input, single child, adjacent identical collapse, distinct messages stay separate (round-1 fix), distinct sources stay separate (round-2 fix), distinct stages stay separate, interleaving breaks the run. - deriveTraceGroups — empty input, lone leaf, requestId grouping, ts-asc child sort, group ts pinned to oldest child, adjacent leaf coalesce with collapsedRange, leaves split by trace stay distinct (round-3 fix), distinct messages stay distinct, reverse-chronological display sort, max-level + total-latency aggregates, original-input adjacency preserved despite display sort. Refactor: coalesceChildren extracted from logs-trace-row.tsx into derive-trace-groups.ts so it's testable as a pure function without React rendering. Refs #1138, #1151 --- ui/src/components/logs/derive-trace-groups.ts | 46 ++++ ui/src/components/logs/logs-trace-row.tsx | Bin 5639 -> 4254 bytes .../logs-derive-trace-groups.test.ts | 231 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts 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 a9efd1aa8a10e4bbe427c880e3c887f4bcef3f15..4b8d70f9dcd8f1bff838299ff028b9c7c8c37f95 100644 GIT binary patch delta 33 pcmZqInWs46j9_wpVoqvta;kGiW=={`YM#!<(?^&$b8-FU1_0(a4Tb;! delta 1425 zcmb7EyKYlK6y#M!e8izi>=5kGCR`$fQXm3CLaCxi+1x$X-ekRN_F-cM>(bFuP$K>T z@d>ni0|j5g?A_}JQ5p)%^6uF=b7toF+k;O#pMQP-{qt_!-`?J!ZF+8nW?wQX$tI{$ zXezDoT*ja#Oyy8%>DZ7m9u*|WD>Kkc!HJT4Ku91jc}5|QajZ8=z zFtsbBFcg7&Jrs`9Tb`hA-v`w8HGb&+$jS()^BBnS*t$ScRTYCpNZdmDM*WN?%Byo! z3ZH;HkA)L~$=Rvz(TgP$Z=)+S5g{)?8}D6>BC4Dnt-v#go2U}}rz#A|h9SGD^89F} zPNi5&p9x6i111jC5uo}mvIj$95>O=!Sxg{GXT7IU)IlYIG8s=b$U<&47C;z4H$pG+ zV$@38P1@4o`-PkulbLe%%Ae7nk6%bZ6sM}-Y0PffTNp`G8?_|K6+>oVCK$;wj>)2j zi+YA~8EYd2bQoK$eQMR;s3Im&C@R}XJa4`4EJCnHM|R*3O>nc5)9xNUb56{jT+xt) zT)W!`LNsBIOdOqq-TWzCZ4jx7mPkEavl&3)q9|y$3q~P2Lno*EC`|baNxc8*P_&OV zhwFUk1DI${Me~(9ps3Cyak$%_&?Qn_(AL(9L#3k!N9uH`$V22Y(!kxsus23QCo8bi zBF?)BfEpHSJ=0uJWzn#nr>cIRjy==s<0FI^%hiMnc4;+I0n%Z2CPE`uNt7UzNm*%v zlEZFY%G*}RP?dE9tSl_WVnD=2_;O_7azcT$Zx7#4jY)4cqv92eX)YS0$yUo{?KaLW z-d%$HNi!QxQaF0e^3uTpRNMLIqTT+Ru9{V+`i4i%`d`Dz{{=S`=S5m?aN4`D%5*SY z>p)6evWhl03EUAiSlo_O!=Y@*!-5`X$L7QuZd|pKH12m=tfimL;JJQ*umqqsLzgWc VfadtD0kuK5@pky}_1>NPe*qFO$EpAT 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); + }); +});