From c0f5413523c8ae0f7cff25ea4de97c4427c5f989 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 15:29:47 -0400 Subject: [PATCH] fix(ui): preserve distinct log detail rows --- ui/src/components/logs/derive-trace-groups.ts | 32 +++++++--- .../logs-derive-trace-groups.test.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index a533fc38..04e0d8ad 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -17,20 +17,30 @@ export interface LeafItem { export type DerivedItem = LeafItem | TraceGroup; +function stableStringify(value: unknown): string { + if (value === undefined) return ''; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(',')}}`; +} + /** - * Tuple key for coalescing standalone leaves. Includes `message` and - * `source` so two adjacent logs that share event/module/level but report - * different content (or come from a different service) stay distinct - * (e.g. `User logged in: alice` and `User logged in: bob`). Excludes - * `latencyMs` and `metadata` because those drift per request even on - * truly redundant polls — including them would prevent any coalescing. + * Tuple key for coalescing standalone leaves. The row/detail UI exposes more + * than event/module/level, so every inspectable payload field must participate + * in equality; otherwise two adjacent logs with different metadata, context, + * error, or latency could collapse into one selectable row. `timestamp` stays + * out of the key because collapsed rows surface their time span separately. * * NB: this only applies to *leaves* (entries without `requestId`). Trace * children render uncoalesced so retries and duplicated-stage emissions * stay individually inspectable. */ function coalesceKey(entry: LogsEntry): string { - return [ + return JSON.stringify([ entry.event ?? '', entry.message ?? '', entry.stage ?? '', @@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string { entry.level, entry.requestId ?? '', entry.source ?? '', - ].join(' '); + entry.runId ?? '', + String(entry.processId ?? ''), + String(entry.latencyMs ?? ''), + stableStringify(entry.context), + stableStringify(entry.metadata), + stableStringify(entry.error), + ]); } /** 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 index 1fef043f..21025f6b 100644 --- a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts +++ b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts @@ -141,6 +141,65 @@ describe('deriveTraceGroups', () => { expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); }); + it('keeps adjacent leaves with different detail payloads as separate rows', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'same', + latencyMs: 25, + metadata: { attempt: 1, nested: { status: 'warm' } }, + context: { account: 'alpha' }, + error: { code: 'E_ONE', message: 'first' }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'same', + latencyMs: 50, + metadata: { attempt: 2, nested: { status: 'cold' } }, + context: { account: 'beta' }, + error: { code: 'E_TWO', message: 'second' }, + } + ) + ); + + expect(result).toHaveLength(2); + expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); + expect(result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'))).toEqual(['2', '1']); + }); + + it('coalesces leaves with semantically identical structured payloads', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { b: 2, a: 1 }, + context: { nested: { b: false, a: true } }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { a: 1, b: 2 }, + context: { nested: { a: true, b: false } }, + } + ) + ); + + expect(result).toHaveLength(1); + expect((result[0] as LeafItem).repeatCount).toBe(2); + }); + it('display-sorts items reverse-chronologically', () => { // Use distinct events so leaves don't coalesce — testing display sort, // not coalesce.