diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index b05bc1f0..ed1207e3 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -21,7 +21,7 @@ import { isVisible } from '../../../core/utility/Util.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import { deepFilter, makeShowDetailsFilter } from '../utils/DetailsFilter.js'; import { expandCollapseAll } from '../utils/ExpandCollapse.js'; -import type { MergedCalltreeRow } from '../utils/MergeAdjacent.js'; +import type { TimeOrderRow } from '../utils/TimeOrderTree.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -111,7 +111,7 @@ export class CalltreeView extends LitElement { rootMethod: ApexLog | null = null; private contextMenu: ContextMenu | null = null; - private contextMenuRow: MergedCalltreeRow | null = null; + private contextMenuRow: TimeOrderRow | null = null; private viewSwitchEpoch = 0; get _callTreeTableWrapper(): HTMLDivElement | null { @@ -653,8 +653,8 @@ export class CalltreeView extends LitElement { this.blockClearHighlights = false; } - _showDetailsFilter = (data: MergedCalltreeRow): boolean => - deepFilter( + _showDetailsFilter = (data: TimeOrderRow): boolean => + deepFilter( data, (row) => { const { duration, isParent, discontinuity, type } = row.originalData; @@ -671,15 +671,15 @@ export class CalltreeView extends LitElement { _showDetailsFilterRollup = (data: AggregatedRow | BottomUpRow): boolean => makeShowDetailsFilter(this.showDetailsFilterCache)(data); - _debugFilter = (data: MergedCalltreeRow | AggregatedRow | BottomUpRow): boolean => - deepFilter( + _debugFilter = (data: TimeOrderRow | AggregatedRow | BottomUpRow): boolean => + deepFilter( data, (row) => !!(row.originalData.type && DEBUG_VALUE_TYPES.has(row.originalData.type)), this.debugOnlyFilterCache, ); - _typeFilter = (data: MergedCalltreeRow | AggregatedRow | BottomUpRow): boolean => - deepFilter( + _typeFilter = (data: TimeOrderRow | AggregatedRow | BottomUpRow): boolean => + deepFilter( data, (row) => { const type = row.originalData.type; @@ -694,13 +694,13 @@ export class CalltreeView extends LitElement { _namespaceFilter = ( selectedNamespaces: string[], _namespace: string, - data: MergedCalltreeRow | AggregatedRow | BottomUpRow, + data: TimeOrderRow | AggregatedRow | BottomUpRow, filterParams: { filterCache: Map }, ): boolean => { if (selectedNamespaces.length === 0) { return true; } - return deepFilter( + return deepFilter( data, (row) => selectedNamespaces.includes(row.namespace || ''), filterParams.filterCache, @@ -828,7 +828,7 @@ export class CalltreeView extends LitElement { return; } - const rowData = row.getData() as MergedCalltreeRow; + const rowData = row.getData() as TimeOrderRow; this.contextMenuRow = rowData; const items: { id: string; label: string; separator?: boolean; shortcut?: string }[] = []; @@ -906,7 +906,7 @@ export class CalltreeView extends LitElement { break; } - const rowEvent = (row.getData() as MergedCalltreeRow).originalData as LogEvent; + const rowEvent = (row.getData() as TimeOrderRow).originalData as LogEvent; const endTime = rowEvent.exitStamp ?? rowEvent.timestamp; if (rowEvent.timestamp === targetEvent.timestamp) { diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index d341ba53..2e2d2e77 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -9,13 +9,13 @@ import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavi import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import { ScrollAnchor } from '../../../tabulator/module/ScrollAnchor.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; -import type { MergedCalltreeRow } from '../utils/MergeAdjacent.js'; +import type { TimeOrderRow } from '../utils/TimeOrderTree.js'; export interface TableCallbacks { namespaceFilter: ( selectedNamespaces: string[], namespace: string, - data: MergedCalltreeRow | AggregatedRow | BottomUpRow, + data: TimeOrderRow | AggregatedRow | BottomUpRow, filterParams: { filterCache: Map }, ) => boolean; onFilterCacheClear: () => void; diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index a499bb0c..da55c64d 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -11,7 +11,7 @@ import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; -import { toUnmergedCallTree, type MergedCalltreeRow } from '../utils/MergeAdjacent.js'; +import { toTimeOrderTree, type TimeOrderRow } from '../utils/TimeOrderTree.js'; import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; import { commonColumnDefaults, @@ -21,7 +21,7 @@ import { } from './TableShared.js'; export interface TimeOrderCallbacks extends TableCallbacks { - showDetailsFilter: (data: MergedCalltreeRow) => boolean; + showDetailsFilter: (data: TimeOrderRow) => boolean; onContextMenu: (e: UIEvent, row: RowComponent) => void; } @@ -39,7 +39,7 @@ export function createTimeOrderTable( const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']); const governorLimits = rootMethod.governorLimits; - const tableData = toUnmergedCallTree(rootMethod.children); + const tableData = toTimeOrderTree(rootMethod.children); const nameFormatter = createCalltreeNameFormatter(excludedTypes); const tableRef: { current: Tabulator | undefined } = { current: undefined }; @@ -83,7 +83,7 @@ export function createTimeOrderTable( if (!(e.target as HTMLElement).matches('a')) { return; } - const node = (cell.getData() as MergedCalltreeRow).originalData; + const node = (cell.getData() as TimeOrderRow).originalData; if (node.hasValidSymbols) { vscodeMessenger.send('openType', node.text); } @@ -278,7 +278,7 @@ export function createTimeOrderTable( tableRef.current = table; // Filter caches are cleared once per render via `renderStarted`. Row ids - // produced by `toUnmergedCallTree` are globally unique within a build + // produced by `toTimeOrderTree` are globally unique within a build // (per-build monotonic counter), so cached `deepFilter` results stay valid // across the cascaded `filter.filter()` passes Tabulator runs for each // expanded subtree — `getChildren` → `filter.filter(config.children)` diff --git a/log-viewer/src/features/call-tree/utils/BottomCalcs.ts b/log-viewer/src/features/call-tree/utils/BottomCalcs.ts index add5cd8a..aea77e41 100644 --- a/log-viewer/src/features/call-tree/utils/BottomCalcs.ts +++ b/log-viewer/src/features/call-tree/utils/BottomCalcs.ts @@ -5,9 +5,9 @@ import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { AggregatedRow, BottomUpRow } from './Aggregation.js'; -import { type MergedCalltreeRow } from './MergeAdjacent.js'; +import { type TimeOrderRow } from './TimeOrderTree.js'; -type CalltreeRowUnion = MergedCalltreeRow | AggregatedRow | BottomUpRow; +type CalltreeRowUnion = TimeOrderRow | AggregatedRow | BottomUpRow; interface InternalRow { getComponent(): RowComponent; diff --git a/log-viewer/src/features/call-tree/utils/MergeAdjacent.ts b/log-viewer/src/features/call-tree/utils/MergeAdjacent.ts deleted file mode 100644 index b1ded3b1..00000000 --- a/log-viewer/src/features/call-tree/utils/MergeAdjacent.ts +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ - -import type { LogEvent, SelfTotal } from 'apex-log-parser'; - -import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; - -/** - * Default gap threshold in nanoseconds (100ms) - * Events within this gap are considered adjacent - */ -const DEFAULT_GAP_THRESHOLD_NS = 100_000_000; - -/** - * Minimum percentage of total merged duration for dynamic gap threshold - */ -const GAP_THRESHOLD_PERCENTAGE = 0.01; // 1% - -/** - * Represents a row in the call tree with potential merging - */ -export interface MergedCalltreeRow { - id: string; - originalData: LogEvent; - _children: MergedCalltreeRow[] | undefined | null; - text: string; - namespace: string; - callerNamespace: string; - duration: SelfTotal; - dmlCount: SelfTotal; - soqlCount: SelfTotal; - dmlRowCount: SelfTotal; - soqlRowCount: SelfTotal; - totalThrownCount: number; - /** Whether this row represents merged events */ - isMerged: boolean; - /** Number of events merged into this row (alias for mergeCount) */ - callCount: number; - /** Number of events merged into this row */ - mergeCount: number; - /** Original events for expansion (only populated for merged rows) */ - mergedEvents: LogEvent[]; - /** Duration statistics for merged rows */ - durationRange?: { min: number; max: number; avg: number }; - /** Average self time per call */ - avgSelfTime: number; -} - -/** - * Generates a signature key for matching adjacent events - * Events with the same key can be merged if adjacent - */ -export function getSignatureKey(event: LogEvent): string { - return `${event.type ?? ''}|${event.text}|${event.lineNumber ?? ''}|${event.namespace}`; -} - -/** - * Determines if two events are close enough to be considered adjacent - */ -function isWithinGapThreshold( - event1: LogEvent, - event2: LogEvent, - thresholdNs: number = DEFAULT_GAP_THRESHOLD_NS, -): boolean { - const event1End = event1.exitStamp ?? event1.timestamp; - const gap = event2.timestamp - event1End; - return gap >= 0 && gap <= thresholdNs; -} - -/** - * Creates a merged row from a group of adjacent events - */ -function createMergedRow(events: LogEvent[], idFor: () => string): MergedCalltreeRow { - const firstEvent = events[0]!; - const totalDuration = events.reduce((sum, e) => sum + e.duration.total, 0); - const totalSelfTime = events.reduce((sum, e) => sum + e.duration.self, 0); - const totalDmlCount = events.reduce((sum, e) => sum + e.dmlCount.total, 0); - const totalSoqlCount = events.reduce((sum, e) => sum + e.soqlCount.total, 0); - const totalDmlRowCount = events.reduce((sum, e) => sum + e.dmlRowCount.total, 0); - const totalSoqlRowCount = events.reduce((sum, e) => sum + e.soqlRowCount.total, 0); - const totalThrownCount = events.reduce((sum, e) => sum + e.totalThrownCount, 0); - - const durations = events.map((e) => e.duration.total); - const minDuration = Math.min(...durations); - const maxDuration = Math.max(...durations); - const avgDuration = totalDuration / events.length; - - // Reserve this row's id first so its prefix sits before any descendant ids - // produced during the recursive merge below. - const id = `merged-${idFor()}`; - - // Create merged children from all events' children - const allChildren: LogEvent[] = []; - for (const event of events) { - allChildren.push(...event.children); - } - const mergedChildren = allChildren.length > 0 ? toMergedCallTree(allChildren, idFor) : null; - - const callCount = events.length; - const avgSelfTime = callCount > 0 ? totalSelfTime / callCount : 0; - - return { - id, - originalData: firstEvent, - _children: mergedChildren, - text: firstEvent.text, - namespace: firstEvent.namespace, - callerNamespace: getCallerNamespace(firstEvent), - duration: { self: totalSelfTime, total: totalDuration }, - dmlCount: { self: totalDmlCount, total: totalDmlCount }, - soqlCount: { self: totalSoqlCount, total: totalSoqlCount }, - dmlRowCount: { self: totalDmlRowCount, total: totalDmlRowCount }, - soqlRowCount: { self: totalSoqlRowCount, total: totalSoqlRowCount }, - totalThrownCount, - isMerged: true, - callCount, - mergeCount: callCount, - mergedEvents: events, - durationRange: { min: minDuration, max: maxDuration, avg: avgDuration }, - avgSelfTime, - }; -} - -/** - * Creates a non-merged row from a single event - */ -function createSingleRow(event: LogEvent, idFor: () => string): MergedCalltreeRow { - // Reserve this row's id before recursing so the parent sits before its - // descendants in the global counter sequence. - const id = `tt-${idFor()}`; - const children = event.children.length > 0 ? toMergedCallTree(event.children, idFor) : null; - - return { - id, - originalData: event, - _children: children, - text: event.text, - namespace: event.namespace, - callerNamespace: getCallerNamespace(event), - duration: event.duration, - dmlCount: event.dmlCount, - soqlCount: event.soqlCount, - dmlRowCount: event.dmlRowCount, - soqlRowCount: event.soqlRowCount, - totalThrownCount: event.totalThrownCount, - isMerged: false, - callCount: 1, - mergeCount: 1, - mergedEvents: [], - avgSelfTime: event.duration.self, - }; -} - -/** - * Converts log events to call tree rows with adjacent event merging - */ -export function toMergedCallTree( - nodes: LogEvent[], - idFor: () => string, -): MergedCalltreeRow[] | undefined { - const len = nodes.length; - if (!len) { - return undefined; - } - - const results: MergedCalltreeRow[] = []; - let i = 0; - - while (i < len) { - const currentEvent = nodes[i]!; - const currentKey = getSignatureKey(currentEvent); - - // Look for adjacent events with the same signature - const adjacentGroup: LogEvent[] = [currentEvent]; - let j = i + 1; - - while (j < len) { - const nextEvent = nodes[j]!; - const nextKey = getSignatureKey(nextEvent); - - // Check if same signature and within gap threshold - if ( - nextKey === currentKey && - isWithinGapThreshold(adjacentGroup[adjacentGroup.length - 1]!, nextEvent) - ) { - adjacentGroup.push(nextEvent); - j++; - } else { - break; - } - } - - // Only merge if we have at least 2 adjacent events - if (adjacentGroup.length >= 2) { - results.push(createMergedRow(adjacentGroup, idFor)); - } else { - results.push(createSingleRow(currentEvent, idFor)); - } - - i = j; - } - - return results; -} - -/** - * Converts log events to call tree rows without merging (regular view). - * The top level is single-row per event; descendants are merged via - * `toMergedCallTree`. Row ids are a per-build monotonic counter so they are - * globally unique within the returned tree, which lets `deepFilter` cache - * results across cascaded Tabulator subtree filter passes without collision. - */ -export function toUnmergedCallTree(nodes: LogEvent[]): MergedCalltreeRow[] | undefined { - const len = nodes.length; - if (!len) { - return undefined; - } - - let next = 0; - const idFor = (): string => String(++next); - - const results: MergedCalltreeRow[] = []; - for (let i = 0; i < len; ++i) { - const node = nodes[i]!; - results.push(createSingleRow(node, idFor)); - } - return results; -} diff --git a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts new file mode 100644 index 00000000..478d9592 --- /dev/null +++ b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { LogEvent, SelfTotal } from 'apex-log-parser'; + +import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; + +/** + * One row per LogEvent for the time-order view; no merging at any level. + */ +export interface TimeOrderRow { + id: string; + originalData: LogEvent; + _children: TimeOrderRow[] | null; + text: string; + namespace: string; + callerNamespace: string; + duration: SelfTotal; + dmlCount: SelfTotal; + soqlCount: SelfTotal; + dmlRowCount: SelfTotal; + soqlRowCount: SelfTotal; + totalThrownCount: number; +} + +/** + * Builds the time-order view: one row per LogEvent at every level. Row ids + * are a per-build monotonic counter (`tt-N`) so they are globally unique + * within the returned tree, which lets `deepFilter` cache results across + * cascaded Tabulator subtree filter passes without collision. + */ +export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { + const len = nodes.length; + if (!len) { + return undefined; + } + + let next = 0; + + function buildRow(event: LogEvent): TimeOrderRow { + const id = `tt-${++next}`; + const children = event.children; + const childCount = children.length; + let mappedChildren: TimeOrderRow[] | null = null; + if (childCount > 0) { + mappedChildren = new Array(childCount); + for (let i = 0; i < childCount; i++) { + mappedChildren[i] = buildRow(children[i]!); + } + } + return { + id, + originalData: event, + _children: mappedChildren, + text: event.text, + namespace: event.namespace, + callerNamespace: getCallerNamespace(event), + duration: event.duration, + dmlCount: event.dmlCount, + soqlCount: event.soqlCount, + dmlRowCount: event.dmlRowCount, + soqlRowCount: event.soqlRowCount, + totalThrownCount: event.totalThrownCount, + }; + } + + const results = new Array(len); + for (let i = 0; i < len; i++) { + results[i] = buildRow(nodes[i]!); + } + return results; +}