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
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 🧠 **Heap analysis**: heap is no longer a single number. Every method and call path now carries three heap metrics, so you can tell a real leak from harmless allocate-then-free churn. ([#32])
- **Net** – bytes retained (allocated minus freed); the lasting footprint. Can be negative where a path frees more than it allocates.
- **Gross** – bytes allocated, ignoring frees; allocation churn and GC pressure.
- **Peak** – highest live heap reached on the path; the number comparable to the heap governor limit.
- Shown together in the **Memory** view (total + self), and used by the Governor Avg/Peak columns and the Call Tree heap column. Method tooltips show net heap retained.
- The Timeline governor strip plots heap as it's allocated, so you can see where it spikes.
- 🗄️ **Database tab + configurable table columns**: governor-limit visibility, a SOSL table, and column views across tables. ([#162] [#298])
- 📏 **Governor-limit overview** (Database): SOQL, SOSL, DML and query/DML rows shown as `used / limit`, colored as they approach the limit.
- 🧮 **Found vs Counted** (Database): each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata, which is free unless it selects a long text area field or runs in a Flow).
- 🔎 **SOSL table**: a dedicated, searchable Database table.
- 🗂️ **Column views** (Call Tree, Analysis, Database): switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view.
- 🏷️ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; **Heap Allocated** (+ Self) via the `Memory` view and Timeline strip; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality).
- 🏷️ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality).
- 🔴 **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828])

### Changed

- ♻️ Replaced the deprecated `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]).
- 🎛️ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]).
- 📊 **Timeline governor limits**: tooltip rows keep a stable order and always show the `used / limit` value, so figures no longer jump around as you move the pointer. ([#827])
- 🚧 **Timeline truncation markers** now end where the log recovers, so trusted sections are no longer greyed out. ([#828])
- 🗂️ **Call Tree + Database styling**: VS Code style tree icons, and rows indent under their group headings. ([#832]).
- 🎛️ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]).
- ♻️ Replaced the deprecated `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]).

## [1.20.0] 2026-06-18

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ The Timeline view shows a live visualization of your Salesforce Apex log executi

- **⚡ Fast** – Blazing-fast zoom, pan, and rendering even on massive logs (500k+ lines).
- **🗺️ Minimap** – Bird's-eye view with skyline density overview, viewport lens, and instant teleport.
- **📊 Governor Limits Strip** – At-a-glance limit usage with traffic light coloring. Expand for detailed step chart.
- **📊 Governor Limits Strip** – At-a-glance limit usage with traffic light coloring, built from individual log events so every limit (heap included) updates as it's consumed, not just at cumulative snapshots. Expand for a detailed step chart.
- **📏 Measure & Zoom** – `Shift+Drag` to measure durations, `Alt/Option+Drag` to area-zoom, precision keyboard controls.
- **🕐 Wall-Clock Time** – Toggle between elapsed and real-time (HH:MM:SS.mmm) on the time axis via the toolbar clock button.

Expand All @@ -99,7 +99,7 @@ Also: Frame Selection & Navigation, Dynamic Frame Labels, Adaptive Frame Detail,

Explore nested method calls with performance metrics:

- **Metrics**: Self Time, Total Time, SOQL/DML/SOSL Counts + Rows, Heap, Governor Limit Avg + Peak, Thrown
- **Metrics**: Self Time, Total Time, SOQL/DML/SOSL Counts + Rows, Heap (net/gross/peak), Governor Limit Avg + Peak, Thrown
- **Call Tree Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution
- **Column Views** – Switch preset column sets (General, Time, Governor Limits, Database, Memory), show/hide columns from the header menu, reset to defaults
- **Group Bottom-Up by Namespace or Type**
Expand Down Expand Up @@ -138,6 +138,17 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues, a

![Database](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/database.png)

## 📊 Governor Limit Tracking

Salesforce enforces governor limits per transaction. This extension tracks them _throughout_ the log, not just as a final tally:

- **Everywhere you look** – limit usage on the Timeline strip, per-path columns in the Call Tree (average + tightest peak), and a `used / limit` overview on the Database tab.
- **Tracked vs consumed** – reconciles what the log shows against the governor-counted total, so you can spot work that didn't count against a limit (e.g. custom metadata SOQL).
- **🧠 Heap, in depth** – heap is the tricky one, since memory gets freed as well as allocated and a single number hides what happened. Every method and call path carries three heap metrics:
- **Net** – bytes retained (allocated minus freed); the lasting footprint.
- **Gross** – bytes allocated, ignoring frees; allocation churn and GC pressure.
- **Peak** – highest live heap reached on the path; the number the heap governor actually enforces.

## 🔍 Global Search

Search across all visualizations:
Expand Down
138 changes: 133 additions & 5 deletions apex-log-parser/__tests__/ApexLogParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ describe('parseLog tests', () => {
expect(thrown?.thrownCount).toEqual({ self: 1, total: 1 });
});

it('heapAllocated is seeded on the allocation leaf and rolled up as total on ancestors', async () => {
it('heapAllocated: leaf seeds self=total=bytes; methods get direct self + rolled-up total', async () => {
const log =
'09:18:22.6 (100)|EXECUTION_STARTED\n\n' +
'15:20:52.222 (200)|METHOD_ENTRY|[185]|01p4J00000FpS6t|Outer.run()\n' +
Expand All @@ -402,9 +402,135 @@ describe('parseLog tests', () => {
const outerAlloc = outer.children.find((child) => child.type === 'HEAP_ALLOCATE');
expect(outerAlloc?.heapAllocated).toEqual({ self: 10, total: 10 });

// Inner rolls up its own 32 bytes; Outer rolls up both (10 + 32).
expect(inner.heapAllocated).toEqual({ self: 0, total: 32 });
expect(outer.heapAllocated).toEqual({ self: 0, total: 42 });
// self = bytes allocated directly by the method's own body (its direct leaf allocations,
// excluding sub-methods); total = self + descendants. Inner directly allocates 32; Outer
// directly allocates 10 (Inner's 32 is not Outer's self) and totals 42.
expect(inner.heapAllocated).toEqual({ self: 32, total: 32 });
expect(outer.heapAllocated).toEqual({ self: 10, total: 42 });

// All allocations are positive here, so gross mirrors net.
expect(inner.heapGross).toEqual({ self: 32, total: 32 });
expect(outer.heapGross).toEqual({ self: 10, total: 42 });
});

it('heapPeak tracks peak live heap (signed net preserved; frees do not lower the peak)', async () => {
// Stats allocates then frees 500k (net 0); Data allocates 5MB and keeps it; View
// frees the 5MB (a negative HEAP_ALLOCATE is the deallocation). Running live heap:
// 500k → 0 → 5MB → 0, so the transaction peak is 5MB, reached under Data.
const log =
'09:18:22.6 (100)|EXECUTION_STARTED\n\n' +
'15:20:52.222 (200)|METHOD_ENTRY|[185]|01pRoot|Root.run()\n' +
'15:20:52.222 (300)|METHOD_ENTRY|[186]|01pCS|Stats.compute()\n' +
'15:20:52.222 (310)|HEAP_ALLOCATE|[52]|Bytes:500000\n' +
'15:20:52.222 (320)|HEAP_ALLOCATE|[52]|Bytes:-500000\n' +
'15:20:52.222 (330)|METHOD_EXIT|[186]|01pCS|Stats.compute()\n' +
'15:20:52.222 (400)|METHOD_ENTRY|[190]|01pLD|Data.load()\n' +
'15:20:52.222 (410)|HEAP_ALLOCATE|[60]|Bytes:5000000\n' +
'15:20:52.222 (420)|METHOD_EXIT|[190]|01pLD|Data.load()\n' +
'15:20:52.222 (500)|METHOD_ENTRY|[200]|01pRD|View.render()\n' +
'15:20:52.222 (510)|HEAP_ALLOCATE|[70]|Bytes:-5000000\n' +
'15:20:52.222 (520)|METHOD_EXIT|[200]|01pRD|View.render()\n' +
'15:20:52.222 (700)|METHOD_EXIT|[185]|01pRoot|Root.run()\n' +
'09:19:13.82 (2000)|EXECUTION_FINISHED\n';

const apexLog = parse(log);
const root = apexLog.children[0]!.children[0]!;
const byText = (text: string) => root.children.find((child) => child.text === text)!;
const stats = byText('Stats.compute()');
const load = byText('Data.load()');
const render = byText('View.render()');

// Signed net allocation is unchanged: Data keeps +5MB, View frees -5MB, the rest net 0.
expect(load.heapAllocated.total).toBe(5000000);
expect(render.heapAllocated.total).toBe(-5000000);
expect(stats.heapAllocated.total).toBe(0);
expect(root.heapAllocated.total).toBe(0);

// Direct/self = the method's own leaf allocations. Data directly allocates +5MB, View
// directly frees -5MB; Root only calls sub-methods (no direct heap leaves), so self = 0.
expect(load.heapAllocated.self).toBe(5000000);
expect(render.heapAllocated.self).toBe(-5000000);
expect(root.heapAllocated.self).toBe(0);

// Gross = positive allocations only (frees ignored): Stats churned 500k (net 0), Data 5MB,
// View 0 (it only frees). Root's gross totals the churn (500k + 5MB), unlike its net (0).
expect(stats.heapGross.total).toBe(500000);
expect(load.heapGross.total).toBe(5000000);
expect(render.heapGross.total).toBe(0);
expect(root.heapGross.total).toBe(5500000);

// Peak live heap: attributed to the allocating subtree, composes to the root, never negative.
expect(stats.heapPeak).toBe(500000);
expect(load.heapPeak).toBe(5000000);
expect(render.heapPeak).toBe(0);
expect(root.heapPeak).toBe(5000000);

// No "Maximum heap size" line in this log, so used falls back to the computed peak.
expect(apexLog.governorLimits.heapSize.used).toBe(5000000);
});

it('net, gross and peak are distinct for an allocate-then-free method', async () => {
// One method allocates 5MB then frees it: net nets to 0 ("no big deal"), but gross shows
// the 5MB churn and peak shows the 5MB transiently held.
const log =
'09:18:22.6 (100)|EXECUTION_STARTED\n\n' +
'15:20:52.222 (200)|METHOD_ENTRY|[1]|01pM|M.a()\n' +
'15:20:52.222 (210)|HEAP_ALLOCATE|[1]|Bytes:5000000\n' +
'15:20:52.222 (220)|HEAP_ALLOCATE|[1]|Bytes:-5000000\n' +
'15:20:52.222 (230)|METHOD_EXIT|[1]|01pM|M.a()\n' +
'09:19:13.82 (2000)|EXECUTION_FINISHED\n';

const method = parse(log).children[0]!.children[0]!;
expect(method.heapAllocated.total).toBe(0); // net: allocated then freed
expect(method.heapGross.total).toBe(5000000); // churn
expect(method.heapPeak).toBe(5000000); // transiently held
});

it('BULK_HEAP_ALLOCATE feeds net/gross/peak the same as HEAP_ALLOCATE', async () => {
const log =
'09:18:22.6 (100)|EXECUTION_STARTED\n\n' +
'15:20:52.222 (200)|METHOD_ENTRY|[1]|01pM|M.a()\n' +
'15:20:52.222 (210)|BULK_HEAP_ALLOCATE|Bytes:1000\n' +
'15:20:52.222 (220)|METHOD_EXIT|[1]|01pM|M.a()\n' +
'09:19:13.82 (2000)|EXECUTION_FINISHED\n';

const method = parse(log).children[0]!.children[0]!;
expect(method.heapAllocated).toEqual({ self: 1000, total: 1000 });
expect(method.heapGross).toEqual({ self: 1000, total: 1000 });
expect(method.heapPeak).toBe(1000);
});

it('governorLimits.heapSize.used is the max of the reported peak and the computed peak', () => {
const withReported = (reportedPeak: number, allocBytes: number) =>
[
'09:18:22.6 (100)|EXECUTION_STARTED',
'15:20:52.222 (200)|METHOD_ENTRY|[1]|01pM|M.a()',
`15:20:52.222 (210)|HEAP_ALLOCATE|[1]|Bytes:${allocBytes}`,
'15:20:52.222 (220)|METHOD_EXIT|[1]|01pM|M.a()',
'12:43:02.105 (300)|LIMIT_USAGE_FOR_NS|(default)|',
` Maximum heap size: ${reportedPeak} out of 6000000`,
'09:19:13.82 (2000)|EXECUTION_FINISHED',
].join('\n');

// Reported (100) below the computed peak (5,000,000) → computed wins.
expect(parse(withReported(100, 5000000)).governorLimits.heapSize.used).toBe(5000000);
// Reported (5000) above the computed peak (100) → reported wins.
expect(parse(withReported(5000, 100)).governorLimits.heapSize.used).toBe(5000);
});

it('governorLimits.heapSize.used is the peak across snapshots, not the last block', () => {
// Heap falls as memory is freed, so a later LIMIT_USAGE_FOR_NS block can report a lower
// (even 0) heap than an earlier one. `used` must be the peak (500000), not the last (0).
const log = [
'09:18:22.6 (100)|EXECUTION_STARTED',
'12:43:02.105 (200)|LIMIT_USAGE_FOR_NS|(default)|',
' Maximum heap size: 500000 out of 6000000',
'12:43:02.105 (300)|LIMIT_USAGE_FOR_NS|(default)|',
' Maximum heap size: 0 out of 6000000',
'09:19:13.82 (2000)|EXECUTION_FINISHED',
].join('\n');

expect(parse(log).governorLimits.heapSize.used).toBe(500000);
});

it('Methods should have line-numbers', async () => {
Expand Down Expand Up @@ -1314,7 +1440,9 @@ describe('Governor Limits Parsing', () => {
publishImmediateDml: { used: 5, limit: 150 },
dmlRows: { used: 118, limit: 10000 },
cpuTime: { used: 17008, limit: 10000 },
heapSize: { used: 400, limit: 6000000 },
// Heap is the transaction PEAK (max across namespace snapshots: max(300, 100)), not the
// sum (400) — global limits aren't summed (#862).
heapSize: { used: 300, limit: 6000000 },
callouts: { used: 3, limit: 100 },
emailInvocations: { used: 6, limit: 10 },
futureCalls: { used: 4, limit: 50 },
Expand Down
40 changes: 40 additions & 0 deletions apex-log-parser/src/ApexLogParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export class ApexLogParser {
reasons: Set<string> = new Set<string>();
lastTimestamp = 0;
discontinuity = false;
/** Running live heap (signed HEAP_ALLOCATE deltas) maintained in log order. */
runningHeap = 0;
namespaces = new Set<string>();
/** Every event created during this parse, indexed by `LogEvent.eventIndex`. */
eventsById: LogEvent[] = [];
Expand Down Expand Up @@ -123,6 +125,17 @@ export class ApexLogParser {
}
}

/**
* Applies a signed heap allocation to the running live-heap total (a negative `bytes` is a
* deallocation) and returns the resulting live-heap level, clamped at 0. Called by the heap
* allocation leaf events in log order; the returned value seeds their `heapPeak`, which is
* then rolled up (by max) to the enclosing methods in {@link aggregateTotals}.
*/
trackHeapAllocation(bytes: number): number {
this.runningHeap += bytes;
return Math.max(0, this.runningHeap);
}

private addGovernorLimits(apexLog: ApexLog) {
const totalLimits = apexLog.governorLimits;
if (totalLimits) {
Expand All @@ -139,6 +152,20 @@ export class ApexLogParser {
currentLimit.used += value.used;
}
}

// Heap is a transaction-wide PEAK, never a per-namespace sum and never the last block
// (heap falls as memory is freed, so last-block/sum both misreport it — see #862 for
// why global limits aren't summed). Take the highest heap any snapshot reported, and the
// computed peak live heap (from HEAP_ALLOCATE events — more precise, and the only source
// when a log has no "Maximum heap size" line). The limit (denominator) is left as the
// per-namespace value the loop set; correcting it needs certified-pool detection (#862).
let reportedHeapPeak = 0;
for (const snapshot of apexLog.governorLimits.snapshots) {
if (snapshot.limits.heapSize.used > reportedHeapPeak) {
reportedHeapPeak = snapshot.limits.heapSize.used;
}
}
totalLimits.heapSize.used = Math.max(reportedHeapPeak, apexLog.heapPeak);
}
}

Expand Down Expand Up @@ -453,6 +480,19 @@ export class ApexLogParser {
parent.duration.self -= child.duration.total;
parent.thrownCount.total += child.thrownCount.total;
parent.heapAllocated.total += child.heapAllocated.total;
parent.heapGross.total += child.heapGross.total;
// Direct/self heap: attribute only leaf allocation children (HEAP_ALLOCATE /
// BULK_HEAP_ALLOCATE, which are not `isParent`) to the enclosing method, so
// `.self` = bytes allocated by this method's own body, excluding sub-methods.
if (!child.isParent) {
parent.heapAllocated.self += child.heapAllocated.self;
parent.heapGross.self += child.heapGross.self;
}
// Peak live heap composes by max (not sum): a parent's peak is the highest
// reached anywhere in its subtree, so root.heapPeak = the transaction peak.
if (child.heapPeak > parent.heapPeak) {
parent.heapPeak = child.heapPeak;
}
}
}
}
Expand Down
Loading