diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index 595d5689..c5162220 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import type { ApexLog } from 'apex-log-parser'; import { Tabulator } from 'tabulator-tables'; diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index a2328d8d..c25d556e 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import type { ApexLog, LogEventType } from 'apex-log-parser'; import { Tabulator, type Options } from 'tabulator-tables'; diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index 1832151b..d341ba53 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Certinia Inc. All rights reserved. + * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { Tabulator } from 'tabulator-tables'; diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index 5c24ecba..520b4339 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import type { ApexLog, LogEventType } from 'apex-log-parser'; import { Tabulator, type RowComponent } from 'tabulator-tables'; diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index 886e1cce..22f96865 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Certinia Inc. All rights reserved. + * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import type { LogEvent, SelfTotal } from 'apex-log-parser'; @@ -233,16 +233,28 @@ function addEventToAggregatedRowWithStack( * reversed caller path. At every node, child partitions must sum to parent for * both self and total. Totals are non-overlapping and recursion-safe. * - * Algorithm (see BOTTOM_UP_CALL_TREE_SPEC.md): + * Input invariant expected from parser output for every metric pair M: + * M.total(node) = M.self(node) + Σ M.total(children) + * This converter assumes that invariant and preserves it through partitioning. + * + * Algorithm: * 1. Compute per-frame attributed totals. For every frame F with name R, * attr(F) = F.total - Σ T for each nearest same-name descendant T. The DFS * maintains Map and subtracts descendant totals * from their nearest same-name ancestor on entry. - * 2. For every frame F with F.duration.self > 0, walk its ancestor chain and + * 2. For every frame F, walk its ancestor chain and * insert F into a trie keyed by [F.name, F.parent.name, F.grandparent.name, …]. * At every prefix, accumulate F.self (bucket.self) and attr(F) (bucket.total) * plus the matching metric pairs. * 3. Finalize averages and sort deterministically (totalSelfTime desc, name asc). + * + * Supported metric pairs (same attribution logic for each pair): + * - duration.self / duration.total + * - dmlCount.self / dmlCount.total + * - soqlCount.self / soqlCount.total + * - dmlRowCount.self / dmlRowCount.total + * - soqlRowCount.self / soqlRowCount.total + * - totalThrownCount (treated like a total metric for attribution) */ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { if (rootChildren.length === 0) { diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index a780ca3f..db0ba23d 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -95,6 +95,15 @@ type PartitionRow = { _children: PartitionRow[] | null; }; +function walkRows(rows: PartitionRow[], visit: (row: PartitionRow) => void): void { + for (const row of rows) { + visit(row); + if (row._children && row._children.length > 0) { + walkRows(row._children, visit); + } + } +} + function assertPartitionInvariant(rows: PartitionRow[]): void { for (const row of rows) { if (!row._children || row._children.length === 0) { @@ -140,6 +149,16 @@ function assertPartitionInvariant(rows: PartitionRow[]): void { } } +function assertRowsDoNotExceedGlobalTotals(rows: PartitionRow[], global: PartitionRow): void { + walkRows(rows, (row) => { + expect(row.totalTime).toBeLessThanOrEqual(global.totalTime); + expect(row.dmlCount.total).toBeLessThanOrEqual(global.dmlCount.total); + expect(row.soqlCount.total).toBeLessThanOrEqual(global.soqlCount.total); + expect(row.dmlRowCount.total).toBeLessThanOrEqual(global.dmlRowCount.total); + expect(row.soqlRowCount.total).toBeLessThanOrEqual(global.soqlRowCount.total); + }); +} + function sumTraceSelfTime(events: LogEvent[]): number { let total = 0; for (const event of events) { @@ -658,7 +677,7 @@ describe('toBottomUpTree', () => { expect(rootSelfBudget).toBe(traceSelfBudget); }); - it('matches worked example A (pure recursion) from BOTTOM_UP_CALL_TREE_SPEC.md', () => { + it('matches worked example A (pure recursion)', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); const outer = createEvent({ text: 'Outer', self: 100, total: 1000, parent: root }); const r1 = createEvent({ text: 'recursive', self: 10, total: 35, parent: outer }); @@ -689,7 +708,7 @@ describe('toBottomUpTree', () => { expect(level3Outer._children).toBeNull(); }); - it('matches worked example B (other / sub other) from BOTTOM_UP_CALL_TREE_SPEC.md', () => { + it('matches worked example B (other / sub other)', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); const outer = createEvent({ text: 'Outer', self: 0, total: 25, parent: root }); const other = createEvent({ text: 'other', self: 10, total: 25, parent: outer }); @@ -713,7 +732,7 @@ describe('toBottomUpTree', () => { expect(otherOuter).toMatchObject({ totalSelfTime: 10, totalTime: 25 }); }); - it('matches worked example C (nested Search) from top-down-to-bottom-up-example.md', () => { + it('matches worked example C (nested Search)', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); const outer = createEvent({ text: 'Outer', self: 100, total: 1000, parent: root }); @@ -875,6 +894,97 @@ describe('toBottomUpTree', () => { expect(limitOnly.dmlCount.self).toBe(1); expect(limitOnly.totalThrownCount).toBe(1); }); + + it('orders roots deterministically by totalSelfTime desc, then name asc for ties', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + createEvent({ text: 'Zulu', self: 30, total: 30, parent: root }); + createEvent({ text: 'Alpha', self: 20, total: 20, parent: root }); + createEvent({ text: 'Beta', self: 20, total: 20, parent: root }); + + const rows = toBottomUpTree(root.children); + expect(rows.map((row) => row.text)).toEqual(['Zulu', 'Alpha', 'Beta']); + }); + + it('keeps every bottom-up total metric within the top-down global root totals', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + const outer = createEvent({ + text: 'Outer', + self: 50, + total: 500, + parent: root, + dmlSelf: 4, + dmlTotal: 40, + soqlSelf: 3, + soqlTotal: 30, + dmlRowSelf: 20, + dmlRowTotal: 200, + soqlRowSelf: 10, + soqlRowTotal: 100, + thrown: 7, + }); + + const search = createEvent({ + text: 'Search', + self: 25, + total: 300, + parent: outer, + dmlSelf: 2, + dmlTotal: 20, + soqlSelf: 1, + soqlTotal: 15, + dmlRowSelf: 10, + dmlRowTotal: 120, + soqlRowSelf: 4, + soqlRowTotal: 60, + thrown: 3, + }); + + createEvent({ + text: 'Search', + self: 10, + total: 150, + parent: search, + dmlSelf: 1, + dmlTotal: 8, + soqlSelf: 1, + soqlTotal: 6, + dmlRowSelf: 5, + dmlRowTotal: 40, + soqlRowSelf: 2, + soqlRowTotal: 20, + thrown: 1, + }); + + createEvent({ + text: 'Worker', + self: 60, + total: 100, + parent: outer, + dmlSelf: 2, + dmlTotal: 12, + soqlSelf: 1, + soqlTotal: 7, + dmlRowSelf: 8, + dmlRowTotal: 35, + soqlRowSelf: 3, + soqlRowTotal: 15, + thrown: 1, + }); + + const rows = toBottomUpTree(root.children) as PartitionRow[]; + const globalRoot: PartitionRow = { + text: outer.text, + totalTime: outer.duration.total, + totalSelfTime: outer.duration.self, + dmlCount: { self: outer.dmlCount.self, total: outer.dmlCount.total }, + soqlCount: { self: outer.soqlCount.self, total: outer.soqlCount.total }, + dmlRowCount: { self: outer.dmlRowCount.self, total: outer.dmlRowCount.total }, + soqlRowCount: { self: outer.soqlRowCount.self, total: outer.soqlRowCount.total }, + _children: null, + }; + + assertRowsDoNotExceedGlobalTotals(rows, globalRoot); + }); }); describe('toAggregatedCallTree', () => {