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

- πŸ”΄ **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828])
- 🧩 **Call Tree, Analysis and Database tables** gain configurable columns and views. ([#298])
- πŸ—‚οΈ **Column views**: switch each table between column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`); edit a view by showing/hiding columns from the **Columns** toolbar button or the header right-click menu, with inline **reset** to restore defaults; choices persist per view.
- πŸ“Š **New columns**: **SOSL Count/Rows**, **Avg Self Time**, and optional **Self** variants for every governor metric.
- 🧠 **Heap / memory analysis**: **Heap Allocated** (+ Self) columns, surfaced through the `Memory` view and the Timeline governor limit strip.
- πŸ”Ž **SOQL Query Plan** view: Relative Cost, Leading Operation, SObject Type and Cardinality.

### Changed

Expand Down Expand Up @@ -505,6 +510,8 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
[#832]: https://github.com/certinia/debug-log-analyzer/issues/832
[#848]: https://github.com/certinia/debug-log-analyzer/issues/848
[#827]: https://github.com/certinia/debug-log-analyzer/issues/827
[#298]: https://github.com/certinia/debug-log-analyzer/issues/298
[#32]: https://github.com/certinia/debug-log-analyzer/issues/32

<!-- v1.20.0 -->

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ Also: Frame Selection & Navigation, Dynamic Frame Labels, Adaptive Frame Detail,

Explore nested method calls with performance metrics:

- **Metrics**: Self Time, Total Time, SOQL/DML/Thrown Counts, SOQL/DML/Rows
- **Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution
- **Metrics**: Self Time, Total Time, SOQL/DML/SOSL Counts + Rows, Heap, 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**
- **Filter by Namespace, Type or Duration**
- **Toggle Debug-Only + Detail Events**
Expand All @@ -114,6 +115,7 @@ Explore nested method calls with performance metrics:
See which methods are the slowest, most frequent. or expensive.

- **Group by Type, Namespace, or Caller Namespace **
- **Column Views** – Preset column sets, show/hide columns, reset to defaults
- **Sort by Duration, Count, Name, Type or Namespace**
- **Filter to specific event types**
- **Copy or Export to CSV**
Expand All @@ -126,6 +128,7 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues.

- **SOQL + DML Duration, Selectivity, Aggregates, Row Count**
- **Group by Namespace, Caller Namespace or Query**
- **Column Views** – Preset column sets (incl. a SOQL Query Plan view), show/hide columns, reset to defaults
- **View the Call Stack**
- **SOQL Optimization Tips**
- **Sort by SOQL or DML, Duration, Selectivity, Aggregates, Row Count**
Expand Down
26 changes: 26 additions & 0 deletions apex-log-parser/__tests__/ApexLogParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,32 @@ 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 () => {
const log =
'09:18:22.6 (100)|EXECUTION_STARTED\n\n' +
'15:20:52.222 (200)|METHOD_ENTRY|[185]|01p4J00000FpS6t|Outer.run()\n' +
'15:20:52.222 (300)|HEAP_ALLOCATE|[52]|Bytes:10\n' +
'15:20:52.222 (400)|METHOD_ENTRY|[190]|01p4J00000FpS6u|Inner.work()\n' +
'15:20:52.222 (500)|HEAP_ALLOCATE|[60]|Bytes:32\n' +
'15:20:52.222 (600)|METHOD_EXIT|[190]|01p4J00000FpS6u|Inner.work()\n' +
'15:20:52.222 (700)|METHOD_EXIT|[185]|01p4J00000FpS6t|Outer.run()\n' +
'09:19:13.82 (2000)|EXECUTION_FINISHED\n';

const apexLog = parse(log);

// children[0] is the EXECUTION_STARTED wrapper; Outer.run() is its child.
const outer = apexLog.children[0]!.children[0]!;
const inner = outer.children.find((child) => child.type === 'METHOD_ENTRY')!;

// The allocation leaf carries self === total === bytes.
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 });
});

it('Methods should have line-numbers', async () => {
const log =
'09:18:22.6 (0)|EXECUTION_STARTED\n\n' +
Expand Down
1 change: 1 addition & 0 deletions apex-log-parser/src/ApexLogParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ export class ApexLogParser {
parent.soslRowCount.total += child.soslRowCount.total;
parent.duration.self -= child.duration.total;
parent.thrownCount.total += child.thrownCount.total;
parent.heapAllocated.total += child.heapAllocated.total;
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions apex-log-parser/src/LogEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,24 @@ export abstract class LogEvent {
total: 0,
};

/**
* Total + self heap bytes allocated (HEAP_ALLOCATE / BULK_HEAP_ALLOCATE).
*
* `self` is seeded on the allocation leaf node (like DML/SOQL counts), so a method's
* `heapAllocated.self` is typically 0 and `heapAllocated.total` is the sum of allocations
* in this node and its children.
*/
heapAllocated: SelfTotal = {
/**
* The net bytes allocated directly by this node.
*/
self: 0,
/**
* The total bytes allocated in this node and child nodes
*/
total: 0,
};

/**
* The line types which would legitimately end this method
*/
Expand Down Expand Up @@ -515,6 +533,7 @@ export class BulkHeapAllocateLine extends LogEvent {
super(parser, parts);
this.text = parts[2] || '';
this.bytes = parseBytes(parts[2]);
this.heapAllocated.self = this.heapAllocated.total = this.bytes;
}
}

Expand Down Expand Up @@ -1103,6 +1122,7 @@ export class HeapAllocateLine extends LogEvent {
this.lineNumber = this.parseLineNumber(parts[2]);
this.text = parts[3] || '';
this.bytes = parseBytes(parts[3]);
this.heapAllocated.self = this.heapAllocated.total = this.bytes;
}
}

Expand Down
4 changes: 4 additions & 0 deletions lana-docs/docs/docs/features/analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Each column can be sorted by clicking the column header, this will sort the rows

1. Show Log events for specific namespaces using the namespace column filter

### Column Views

The same presets as the Call Tree (General, Time, Governor Limits, Database, Memory), from the **Columns** button in the toolbar (or the header right-click menu). Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per view.

### Group

The rows can be grouped by Type, Namespace, or Caller Namespace.
Expand Down
12 changes: 12 additions & 0 deletions lana-docs/docs/docs/features/calltree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ Clicking the link in the event column will open the corresponding file and line,

Each column can be sorted by clicking the column header, this will sort the rows within the tree structure e.g sorting by self time will sort the children within a parent with the largest self time to the top but only within that parent.

### Column Views

Switch column sets from the **Columns** button in the toolbar (or the header right-click menu):

1. **General** – everyday overview.
1. **Time** – call count and self/total/average time.
1. **Governor Limits** – DML/SOQL/SOSL counts + rows, throws, heap, and governor limit usage (avg + peak).
1. **Database** – DML/SOQL/SOSL counts + rows.
1. **Memory** – heap self and total.

Show or hide individual columns from the same menu; an edited view shows a **reset** icon. Choices persist per view.

### Filtering

1. Details (events with 0 time) are hidden by default but can be shown/ hidden.
Expand Down
4 changes: 4 additions & 0 deletions lana-docs/docs/docs/features/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ If the grouping is removed the sorting applies the same but across all rows inst

1. In the SOQL view show Log events for specific namespaces using the namespace column filter

### Column Views

Switch column sets from the **Columns** button in the toolbar (or the header right-click menu). SOQL offers **General**, **Performance** and **Query Plan** (Relative Cost, Leading Operation, SObject Type, Cardinality); DML offers **General** and **Timing**. Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per table.

### Group

By default rows are grouped by the SOQL/ DML text, grouping can be removed and the rows shows as a flat list using the _Group by_ item in the header menu. The groups are default sorted with the groups with the most items at the top.
Expand Down
45 changes: 45 additions & 0 deletions lana/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@
"default": false,
"markdownDescription": "Tint the Call Tree Name column by event category, matching the Timeline colors, instead of showing a small color chip. Default: `false`",
"order": 0
},
"lana.callTree.columnView": {
"title": "Column view",
"type": "string",
"default": "General",
"enum": [
"General",
"Time",
"Governor Limits",
"Database",
"Memory"
],
"markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`",
"order": 1
}
}
},
Expand Down Expand Up @@ -326,6 +340,37 @@
}
}
}
},
{
"type": "object",
"id": "lana",
"title": "Database",
"order": 2,
"properties": {
"lana.database.soql.columnView": {
"title": "SOQL column view",
"type": "string",
"default": "General",
"enum": [
"General",
"Performance",
"Query Plan"
],
"markdownDescription": "The column view applied to the Database SOQL table. Default: `General`",
"order": 0
},
"lana.database.dml.columnView": {
"title": "DML column view",
"type": "string",
"default": "General",
"enum": [
"General",
"Timing"
],
"markdownDescription": "The column view applied to the Database DML table. Default: `General`",
"order": 2
}
}
}
]
},
Expand Down
27 changes: 25 additions & 2 deletions lana/src/commands/LogView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import type { Context } from '../Context.js';
import { OpenFileInPackage } from '../display/OpenFileInPackage.js';
import { WebView } from '../display/WebView.js';
import { RawLogNavigation } from '../log-features/RawLogNavigation.js';
import { getConfig } from '../workspace/AppConfig.js';
import {
COLUMN_OVERRIDE_SECTIONS,
getColumnOverrides,
getConfig,
updateColumnOverride,
updateConfig,
} from '../workspace/AppConfig.js';

interface WebViewLogFileRequest<T = unknown> {
requestId: string;
Expand Down Expand Up @@ -102,14 +108,31 @@ export class LogView {
}

case 'getConfig': {
const config = getConfig();
const overrides = getColumnOverrides(context.context.globalState);
config.callTree.columnOverrides = overrides['callTree.columnOverrides'] ?? {};
config.database.soql.columnOverrides = overrides['database.soql.columnOverrides'] ?? {};
config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {};
panel.webview.postMessage({
requestId,
cmd: 'getConfig',
payload: getConfig(),
payload: config,
});
break;
}

case 'updateConfig': {
const { section, value } = payload as { section: string; value: unknown };
if (section) {
if ((COLUMN_OVERRIDE_SECTIONS as readonly string[]).includes(section)) {
updateColumnOverride(context.context.globalState, section, value);
} else {
updateConfig(section, value);
}
}
break;
}

case 'saveFile': {
const { fileContent, options } = payload as {
fileContent: string;
Expand Down
36 changes: 35 additions & 1 deletion lana/src/workspace/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) 2020 Certinia Inc. All rights reserved.
*/

import { ConfigurationTarget, workspace } from 'vscode';
import { ConfigurationTarget, workspace, type Memento } from 'vscode';

interface Config {
timeline: {
Expand Down Expand Up @@ -35,6 +35,12 @@ interface Config {
};
callTree: {
categoryColorize: boolean;
columnView: string;
columnOverrides: Record<string, string[]>;
};
database: {
soql: { columnView: string; columnOverrides: Record<string, string[]> };
dml: { columnView: string; columnOverrides: Record<string, string[]> };
};
}

Expand All @@ -60,3 +66,31 @@ export function updateConfig(section: string, value: unknown): Thenable<void> {
const config = workspace.getConfiguration('lana');
return config.update(section, value, ConfigurationTarget.Global);
}

/**
* Column overrides are opaque per-view field maps β€” private UI state, not user
* preferences β€” so they persist in globalState rather than editable settings.
*/
export const COLUMN_OVERRIDE_SECTIONS = [
'callTree.columnOverrides',
'database.soql.columnOverrides',
'database.dml.columnOverrides',
] as const;

type ColumnOverrides = Record<string, string[]>;

export function getColumnOverrides(globalState: Memento): Record<string, ColumnOverrides> {
const overrides: Record<string, ColumnOverrides> = {};
for (const section of COLUMN_OVERRIDE_SECTIONS) {
overrides[section] = globalState.get<ColumnOverrides>(section, {});
}
return overrides;
}

export function updateColumnOverride(
globalState: Memento,
section: string,
value: unknown,
): Thenable<void> {
return globalState.update(section, value);
}
49 changes: 49 additions & 0 deletions lana/src/workspace/__tests__/AppConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { describe, expect, it, jest } from '@jest/globals';
import type { Memento } from 'vscode';

import {
COLUMN_OVERRIDE_SECTIONS,
getColumnOverrides,
updateColumnOverride,
} from '../AppConfig.js';

function mockMemento(store: Record<string, unknown> = {}): Memento {
return {
keys: jest.fn(() => Object.keys(store)),
get: jest.fn((key: string, fallback?: unknown) =>
key in store ? store[key] : fallback,
) as Memento['get'],
update: jest.fn(() => Promise.resolve()),
} as unknown as Memento;
}

describe('AppConfig column overrides', () => {
describe('getColumnOverrides', () => {
it('reads each override section, defaulting to {}', () => {
const globalState = mockMemento({
'callTree.columnOverrides': { Time: ['a', 'b'] },
});

const overrides = getColumnOverrides(globalState);

expect(overrides['callTree.columnOverrides']).toEqual({ Time: ['a', 'b'] });
expect(overrides['database.soql.columnOverrides']).toEqual({});
expect(overrides['database.dml.columnOverrides']).toEqual({});
expect(globalState.get).toHaveBeenCalledTimes(COLUMN_OVERRIDE_SECTIONS.length);
});
});

describe('updateColumnOverride', () => {
it('writes only to globalState', () => {
const globalState = mockMemento();
const value = { Time: ['a'] };

updateColumnOverride(globalState, 'callTree.columnOverrides', value);

expect(globalState.update).toHaveBeenCalledWith('callTree.columnOverrides', value);
});
});
});
Loading