fix(ui): remove intra-trace coalescing — preserve every stage for inspection

PR-Agent flagged that the trace-child coalesce was hiding legitimate
repeated stage emissions: a request that retries an upstream call or
emits the same stage twice for any reason would show only the first
occurrence behind a `×N` badge, with subsequent attempts no longer
selectable or inspectable.

The original noise problem motivating intra-trace coalesce was the
149-stage `web-server:http` self-polling trace, but that's already
hidden by default via the `hideDashboardInternals` filter. Users who
opt in to see internals are deliberately debugging the dashboard and
SHOULD see all 149 stages — collapsing them defeats the opt-in.

Drop `coalesceChildren` entirely. Trace children render uncoalesced
so retries, duplicated stages, and multi-attempt traces stay
individually inspectable. Standalone-leaf coalesce stays — that's
the actual user-facing dedup the PR was about.

Tests: drop the 7 `coalesceChildren` cases; keep 16 covering
`deriveStageHint` and `deriveTraceGroups`.

Refs #1138, #1151
This commit is contained in:
Tam Nhu Tran committed 2026-04-30 16:28:42 -04:00
1 parent 6ada2b243e
commit 2ddc5d5afd
3 files changed
+22 -104

No files matched your search

+9 -49
View File
@@ -18,11 +18,16 @@ export interface LeafItem {
export type DerivedItem = LeafItem | TraceGroup;
/**
* Tuple key for coalescing. Includes `message` so two adjacent logs that
* share event/module/level but report different content stay distinct
* 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.
*
* 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 [
@@ -35,52 +40,6 @@ 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
@@ -108,7 +67,8 @@ export function deriveStageHint(entry: LogsEntry): string | undefined {
* Trace groups gather all children sharing a requestId regardless of
* interleaving; they're sorted by `ts asc` before display, with the
* group's positional `ts` set to the oldest child so it slots correctly
* in the reverse-chronological display sort below.
* in the reverse-chronological display sort below. Children are NOT
* coalesced — every stage is preserved for individual inspection.
*/
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
const items: DerivedItem[] = [];
+13 -7
View File
@@ -2,7 +2,7 @@ import { memo } from 'react';
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { coalesceChildren, deriveStageHint } from './derive-trace-groups';
import { deriveStageHint } from './derive-trace-groups';
import { LogLevelBadge } from './log-level-badge';
import { LogsRow } from './logs-row';
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
@@ -110,18 +110,24 @@ function LogsTraceRowImpl({
{group.requestId.slice(-8)}
</span>
</button>
{/* Children render unconditionally — every stage is preserved so
retries, duplicated stage logs, and multi-attempt traces stay
individually inspectable. The user-facing noise problem this
PR addresses lives at the standalone-leaf level (dashboard
self-polling spam) where leaf-coalesce in deriveTraceGroups
handles it; opting in to see internals is a deliberate debug
choice, so collapsing trace stages there would hide intent. */}
{isExpanded
? coalesceChildren(group.children).map((run) => (
? group.children.map((child) => (
<LogsRow
key={run.head.id}
entry={run.head}
isSelected={run.head.id === selectedEntryId}
key={child.id}
entry={child}
isSelected={child.id === selectedEntryId}
density={density}
sourceLabel={sourceLabel}
onSelect={onSelect}
indent={20}
stageHint={deriveStageHint(run.head)}
repeatCount={run.count > 1 ? run.count : undefined}
stageHint={deriveStageHint(child)}
/>
))
: null}