Turn a Salesforce Apex debug log into a typed event tree with execution timings, governor limits and SOQL/DML counts.
It is the parser behind the Apex Log Analyzer VS Code extension and its MCP server.
- 171 event types parsed into their own classes, covering methods, SOQL, DML, flows, callouts and more. Other lines use a generic class, so none are dropped
- An event tree with parent/child links, where each entry event is matched to its exit
- Execution time per node, self and total, in nanoseconds
- Governor limits per namespace, with a snapshot for each limit block in the log
- Each limit line's reading as
{ metric, used, limit } - SOQL, DML and SOSL counts summed up the tree
- Managed package namespaces, with metrics for each
- No dependencies, ESM only
npm install @apexdevtools/apex-log-parserGiven this log:
64.0 APEX_CODE,FINE;APEX_PROFILING,FINEST;CALLOUT,NONE;DB,INFO;NBA,NONE;SYSTEM,NONE;VALIDATION,NONE;VISUALFORCE,NONE;WAVE,NONE;WORKFLOW,NONE
09:18:22.6 (6508409)|USER_INFO|[EXTERNAL]|005000000000AAA|user@example.com|Greenwich Mean Time|GMTZ
09:18:22.6 (6574780)|EXECUTION_STARTED
09:18:22.6 (6600000)|CODE_UNIT_STARTED|[EXTERNAL]|01p4J00000FpS6t|AccountService.refresh()
09:18:22.6 (7000000)|METHOD_ENTRY|[12]|01p4J00000FpS6t|AccountService.loadAccounts()
09:18:22.6 (7100000)|SOQL_EXECUTE_BEGIN|[14]|Aggregations:0|SELECT Id, Name FROM Account WHERE Industry = :industry
09:18:22.6 (9100000)|SOQL_EXECUTE_END|[14]|Rows:50
09:18:22.6 (9200000)|METHOD_EXIT|[12]|01p4J00000FpS6t|AccountService.loadAccounts()
09:18:22.6 (9300000)|DML_BEGIN|[20]|Op:Update|Type:Account|Rows:50
09:18:22.6 (9800000)|DML_END|[20]
09:18:22.6 (9900000)|CODE_UNIT_FINISHED|AccountService.refresh()
09:18:22.6 (10100000)|EXECUTION_FINISHED
This code:
import { type LogEvent, parse } from '@apexdevtools/apex-log-parser';
function printTree(node: LogEvent, depth = 0): void {
const indent = ' '.repeat(depth);
const ms = (node.duration.total / 1_000_000).toFixed(2);
console.log(`${indent}${node.type ?? 'LOG_ROOT'} ${node.text} (${ms}ms)`);
for (const child of node.children) {
printTree(child, depth + 1);
}
}
const log = parse(logData);
printTree(log);Prints:
LOG_ROOT LOG_ROOT (3.53ms)
EXECUTION_STARTED EXECUTION_STARTED (3.53ms)
CODE_UNIT_STARTED AccountService.refresh() (3.30ms)
METHOD_ENTRY AccountService.loadAccounts() (2.20ms)
SOQL_EXECUTE_BEGIN SELECT Id, Name FROM Account WHERE Industry = :industry (2.00ms)
DML_BEGIN DML Op:Update Type:Account (0.50ms)
Note the shape. parse() returns the root, which is itself a LogEvent, so the same walk works
from any node. METHOD_EXIT, SOQL_EXECUTE_END and DML_END are not nodes of their own. Each
one closes its matching begin event and sets that event's exitStamp and duration.
The root sums the whole tree, so SOQL and DML totals need no walk. Governor limits are on the root
too: final is what the transaction had used when the log ended, and peak is the highest each
metric reached. Check peak against a limit, because counters can fall mid-log.
To rank methods, walk the tree and sort on duration.self. It excludes children, so it measures
time spent in the method itself rather than in what it called.
import { type LogEvent, MethodEntryLine, parse } from '@apexdevtools/apex-log-parser';
const log = parse(logData);
const { final, peak } = log.governorLimits;
console.log(`SOQL: ${log.soqlCount.total} queries, ${log.soqlRowCount.total} rows`);
console.log(`DML: ${log.dmlCount.total} statements, ${log.dmlRowCount.total} rows`);
console.log(`CPU: ${final.cpuTime.used}/${final.cpuTime.limit}ms`);
console.log(`SOQL: ${peak.soqlQueries.used} at peak (${peak.soqlQueries.percentUsed}%)`);
console.log(`Heap: ${peak.heapSize.used}/${peak.heapSize.limit} bytes`);
const methods: MethodEntryLine[] = [];
const stack: LogEvent[] = [log];
for (let node = stack.pop(); node; node = stack.pop()) {
if (node instanceof MethodEntryLine) {
methods.push(node);
}
stack.push(...node.children);
}
methods.sort((a, b) => b.duration.self - a.duration.self);
console.table(methods.slice(0, 10).map((m) => ({ method: m.text, selfNs: m.duration.self })));parse(logData: string): ApexLog is the whole entry point. There is no state to reset
between calls, and ApexLogParser.parse gives each call its own parser. ApexLog is the root
LogEvent, and adds governorLimits, namespaces, debugLevels, userInfo, entryPoint,
truncation, logIssues, parsingErrors, exceptions and eventsById.
There are two entry points. The root exports runtime values only: parse, the ApexLogParser
class, and the event classes you need for instanceof narrowing. Every type, and the
const companions that go with them, come from @apexdevtools/apex-log-parser/types:
import { parse } from '@apexdevtools/apex-log-parser';
import { LOG_LEVEL, type GovernorLimits } from '@apexdevtools/apex-log-parser/types';The type declarations in the package document every field, event class and type, so your editor shows them.
Capture the log at the right levels. The parser reports what the log contains. A log captured at a low level is missing whole categories of line, and the matching fields stay empty. This is the most common surprise:
| You want | The log needs |
|---|---|
duration on method nodes |
APEX_CODE at FINE or above, plus APEX_PROFILING |
governorLimits |
APEX_PROFILING at FINE or above, which emits the CUMULATIVE_LIMIT_USAGE block |
| SOQL and DML nodes | DB at INFO or above |
| Flow and Process Builder limit lines | WORKFLOW at FINER |
All-zero limits mean "not reported". Without a LIMIT_USAGE_FOR_NS block, every
governorLimits.final and governorLimits.peak metric stays at
{ used: 0, limit: 0, percentUsed: null }. That is not a transaction that used nothing.
Read totals from the root. It already sums the tree, so you don't need to walk it to count SOQL or DML.
Use eventIndex as an id. It is unique, increasing and stable across a parse.
Two collections, two meanings. parsingErrors holds lines the parser did not understand, which is a
parser problem. logIssues holds problems in the transaction the log describes, such as a
truncated log or an unexpected exit.
- Node.js 20 or later. The package targets ES2022 and runs in any runtime with ES modules: Node, Deno, Bun and modern browsers. It reads no files and makes no network calls.
- ESM only. There is no CommonJS build, so
require()does not work. - TypeScript declarations ship with the package. No
@typesinstall is needed.
See CONTRIBUTING.md for development setup, coding standards, and the PR process.
BSD-3-Clause - Certinia Inc.