Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
18 changes: 15 additions & 3 deletions log-viewer/src/features/call-tree/utils/Aggregation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<name, deepest-active-frame> 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) {
Expand Down
116 changes: 113 additions & 3 deletions log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading