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
1 change: 1 addition & 0 deletions .changeset/v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ How it works now, and what breaks:
- **Ids.** `eventIndex` is now `id`: from 1, valid for one parse. `log.event(id)`, `log.events`. `log.columns` gives times and tree links as typed arrays.
- **Times.** `timestamp` β†’ `startTimestamp`, `exitStamp` β†’ `endTimestamp`. A leaf ends where it starts.
- **`null` means not stated.** Not `''`, `0` or `'default'`: `text`, `namespace`, `lineNumber`, a limit's `limit`.
- **`FLOW_ELEMENT_ERROR`.** `text` is the message only; it ended with the element type and name, now `details.elementType` and `details.elementName`.
- **Removed:** `suffix`, `category`, `executionEndTime`, parser state on events (`isExit`, `acceptsText` and so on). `cpuType` is now `eventType(type).consumesCpuTime`. `hasValidSymbols` is now `isApexCall`.
- **Renamed:** `logIssues` β†’ `issues`, `governorLimits` β†’ `limits`, `LOG_LEVEL` β†’ `LEVEL`. `./next` paths are gone: import from the root.
- **Types:** `LogEventType` β†’ `EventType`, `LogLevel` β†’ `Level`, `GovernorSnapshot` β†’ `LimitSnapshot`, `Truncation` β†’ `LogTruncation`, `TruncationRegion` β†’ `LogTruncationRegion`, `LimitMetricKey` β†’ `LimitMetric`, `LimitObservation` β†’ `LimitUsage`, `RunningTotalObservation` β†’ `RunningUsage`. Removed: `LIMIT_METRIC`, `ALL_LIMIT_METRICS`, `LimitMetricUnit`, `LimitMetricMeta`, `DebugLevels`, `LineNumber`.
6 changes: 4 additions & 2 deletions src/__tests__/Buffers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const LIMITS = [
'09:00:00.0 (900)|CUMULATIVE_LIMIT_USAGE_END',
];

// Every part of a build: the header, namespaces, counts, heap, details, a flow running total,
// limits, a skipped block, a frame left open and a line the parser cannot read.
// Every part of a build: the header, namespaces, counts, heap, details, a flow running total, a
// flow error, limits, a skipped block, a frame left open and a line the parser cannot read.
const LOG = [
'64.0 APEX_CODE,FINE;APEX_PROFILING,INFO;CALLOUT,INFO;DB,INFO;SYSTEM,DEBUG;WORKFLOW,INFO',
'09:15:30.25 (1)|USER_INFO|[EXTERNAL]|005000000000AAA|user@example.com|(GMT-08:00) Pacific Standard Time (America/Los_Angeles)|GMT-08:00',
Expand All @@ -39,6 +39,7 @@ const LOG = [
'09:00:00.0 (100)|FLOW_ELEMENT_BEGIN|abc-1|FlowRecordUpdate|Update_Account',
'09:00:00.0 (110)|FLOW_ELEMENT_LIMIT_USAGE|1 DML statements, total 1 out of 150',
'09:00:00.0 (120)|FLOW_ELEMENT_END|abc-1|FlowRecordUpdate|Update_Account',
'09:00:00.0 (130)|FLOW_ELEMENT_ERROR|Required fields are missing.|FlowRecordUpdate|Update_Account',
...LIMITS,
'*** Skipped 1,024 bytes of detailed log',
'09:00:00.0 (1000)|NOT_A_REAL_EVENT|x',
Expand Down Expand Up @@ -113,6 +114,7 @@ it('the fixture states every part of a build', async () => {
expect(log.heapPeak).toBeGreaterThan(0);
expect(log.ofType('FLOW_ELEMENT_BEGIN')[0]?.dmlCount.self).toBe(1);
expect(log.ofType('SOQL_EXECUTE_EXPLAIN')[0]?.details).not.toBeNull();
expect(log.ofType('FLOW_ELEMENT_ERROR')[0]?.details.elementName).toBe('Update_Account');
});

describe.each([
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/Details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,52 @@ describe('CODE_UNIT_STARTED details', () => {
});
});

describe('FLOW_ELEMENT_ERROR details', () => {
it.each([
[
'a one-line message',
[
'09:18:22.6 (200)|FLOW_ELEMENT_ERROR|Required fields are missing.|FlowRecordCreate|Create_Account',
],
{ elementType: 'FlowRecordCreate', elementName: 'Create_Account' },
],
[
'a message over lines',
[
'09:18:22.6 (200)|FLOW_ELEMENT_ERROR|You have reached the limit.',
'Try again later.|FlowActionCall|myRule_1_A1',
],
{ elementType: 'FlowActionCall', elementName: 'myRule_1_A1' },
],
[
'a message that holds a |',
['09:18:22.6 (200)|FLOW_ELEMENT_ERROR|Bad value: a|b|FlowDecision|Check_Status'],
{ elementType: 'FlowDecision', elementName: 'Check_Status' },
],
[
'no element',
['09:18:22.6 (200)|FLOW_ELEMENT_ERROR|An error occurred.'],
{ elementType: null, elementName: null },
],
[
'an empty message',
['09:18:22.6 (200)|FLOW_ELEMENT_ERROR||FlowDecision|Check_Status'],
{ elementType: 'FlowDecision', elementName: 'Check_Status' },
],
[
'an element line that another line follows',
[
'09:18:22.6 (200)|FLOW_ELEMENT_ERROR|You have reached the limit.',
'|FlowActionCall|myRule_1_A1',
'Try again later.',
],
{ elementType: null, elementName: null },
],
])('reads the element after %s', (_name, lines, expected) => {
expect(parse(inExecution(...lines)).ofType('FLOW_ELEMENT_ERROR')[0]?.details).toEqual(expected);
});
});

describe('limit and heap details', () => {
const log = parse(
inExecution(
Expand Down
22 changes: 14 additions & 8 deletions src/__tests__/TextRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@
import { parse } from './helpers.js';

describe('FLOW_ELEMENT_ERROR', () => {
it('keeps the error message, element type and element name', () => {
it('keeps the error message only', () => {
const log = parse(
'09:18:22.6 (100)|FLOW_ELEMENT_ERROR|Required fields are missing: [Name]|FlowRecordCreate|Create_Account\n',
);

expect(log.children[0]?.text).toBe(
'Required fields are missing: [Name] FlowRecordCreate Create_Account',
);
expect(log.children[0]?.text).toBe('Required fields are missing: [Name]');
});

it('reads the element type and name after a message that spans lines', () => {
it('keeps the whole message when it spans lines', () => {
const log = parse(
'09:18:22.6 (100)|FLOW_ELEMENT_ERROR|You have reached the limit.\n' +
'Actions will start again in the next hour.\n' +
Expand All @@ -23,8 +21,16 @@ describe('FLOW_ELEMENT_ERROR', () => {
);

expect(log.children[0]?.text).toBe(
'You have reached the limit.\nActions will start again in the next hour. FlowActionCall myRule_1_A1',
'You have reached the limit.\nActions will start again in the next hour.',
);
});

it('keeps a | in the message', () => {
const log = parse(
'09:18:22.6 (100)|FLOW_ELEMENT_ERROR|Bad value: a|b|FlowDecision|Check_Status\n',
);

expect(log.children[0]?.text).toBe('Bad value: a|b');
});

it('keeps a message that states no element', () => {
Expand All @@ -35,10 +41,10 @@ describe('FLOW_ELEMENT_ERROR', () => {
expect(log.children[0]?.text).toBe('An error occurred.\n --- An Apex error occurred');
});

it('leaves no leading space when the message is empty', () => {
it('states no text when the message is empty', () => {
const log = parse('09:18:22.6 (100)|FLOW_ELEMENT_ERROR||FlowDecision|Check_Status\n');

expect(log.children[0]?.text).toBe('FlowDecision Check_Status');
expect(log.children[0]?.text).toBeNull();
});
});

Expand Down
20 changes: 14 additions & 6 deletions src/catalog/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
vfApexCallText,
wfFormulaAfter,
} from './text.js';

export { splitTrailingFields } from './text.js';

import type {
DebugCategory,
EventType,
Expand Down Expand Up @@ -1909,19 +1912,24 @@ export function idOfType(name: string): number {
}

/**
* An event's text: its type's rule, then its continuation lines if the type takes text, then its
* type's rewrite. Null when neither gives any text.
* An event's text before its type's rewrite: its type's rule, then its continuation lines if the
* type takes text. Null when neither gives any text.
*/
export function eventText(typeId: number, f: Fields): string | null {
export function eventBody(typeId: number, f: Fields): string | null {
const text = LINE_RULES[typeId];
const grammar = GRAMMAR[typeId];
if (!text || !grammar) return null;
// An empty field is no text, so that a missing field and an absent rule read the same.
const base = text.rule?.(f) || null;
const more = grammar.acceptsText ? f.continuation() : '';
const joined = base === null ? more : more ? `${base}\n${more}` : base;
if (!joined) return null;
return text.after ? text.after(joined) : joined;
return (base === null ? more : more ? `${base}\n${more}` : base) || null;
}

/** An event's text: its body, then its type's rewrite. Null when no text is left. */
export function eventText(typeId: number, f: Fields): string | null {
const body = eventBody(typeId, f);
const after = LINE_RULES[typeId]?.after;
return body !== null && after ? after(body) || null : body;
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/catalog/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function lineText(field: string): string {
}

/** Splits the last `count` `|` fields off text, or null when a field spans lines. */
function splitTrailingFields(text: string, count: number): string[] | null {
export function splitTrailingFields(text: string, count: number): string[] | null {
const parts = text.split('|');
if (parts.length <= count) return null;
const fields = parts.splice(-count);
Expand Down Expand Up @@ -124,8 +124,8 @@ export const flowInterviewsErrorAfter: AfterRule = (text) => {
return split ? `${split[0]} - ${split[2]}` : text;
};

export const flowElementErrorAfter: AfterRule = (text) =>
splitTrailingFields(text, 2)?.filter(Boolean).join(' ') ?? text;
// The element type and name are details, not text.
export const flowElementErrorAfter: AfterRule = (text) => splitTrailingFields(text, 2)?.[0] ?? text;

export const validationFormulaAfter: AfterRule = (text) => {
const split = splitTrailingFields(text, 1);
Expand Down
21 changes: 20 additions & 1 deletion src/views/details.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { codeUnitType, EVENT_TYPES, fieldPosition, idOfType } from '../catalog/catalog.js';
import {
codeUnitType,
EVENT_TYPES,
eventBody,
fieldPosition,
idOfType,
splitTrailingFields,
} from '../catalog/catalog.js';
import type { EventType, Fields } from '../catalog/types.js';
import type { LimitUsage, RunningUsage } from '../limits.js';
import { codedUsage, labelledUsage, runningUsage } from '../limits.js';
Expand Down Expand Up @@ -46,6 +53,11 @@ export interface EventDetails {
readonly FLOW_INTERVIEW_FINISHED_LIMIT_USAGE: LimitUsage | null;
readonly FLOW_ELEMENT_LIMIT_USAGE: RunningUsage | null;
readonly FLOW_BULK_ELEMENT_LIMIT_USAGE: RunningUsage | null;
/** The element that failed. Its text holds the message only. */
readonly FLOW_ELEMENT_ERROR: {
readonly elementType: string | null;
readonly elementName: string | null;
};
}

/** The details of an event of type `T`: null for a type that states none. */
Expand Down Expand Up @@ -174,6 +186,13 @@ for (const [type, parse] of FLOW_USAGE) {
});
}

reader('FLOW_ELEMENT_ERROR', (t) => (f) => {
// A message can span lines; then the element follows it on the last line.
const body = eventBody(t, f);
const split = body === null ? null : splitTrailingFields(body, 2);
return { elementType: split?.[1] || null, elementName: split?.[2] || null };
});

/** The details `typeId`'s line states, or null for a type that has none. Frozen: views share them. */
export function eventDetails(
typeId: number,
Expand Down
Loading