From 76a02512459ceac0902b2276a1c183cc83771467 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:11:37 +0100 Subject: [PATCH] feat!: keep only the message in FLOW_ELEMENT_ERROR text The text of a FLOW_ELEMENT_ERROR is now the message only. The element type and name move to details, as elementType and elementName. The parser reads them from the event body, the text before its rewrite, so they are correct when the message spans lines or holds a |. eventBody is now the one place that joins a line's text and its continuation lines. An event text that is empty after its rewrite rule is now null. BREAKING CHANGE: FLOW_ELEMENT_ERROR text no longer ends with the element type and name, and it is null when the message is empty. Read details.elementType and details.elementName instead. Refs #123 --- .changeset/v1.md | 1 + src/__tests__/Buffers.test.ts | 6 +++-- src/__tests__/Details.test.ts | 46 +++++++++++++++++++++++++++++++++ src/__tests__/TextRules.test.ts | 22 ++++++++++------ src/catalog/catalog.ts | 20 +++++++++----- src/catalog/text.ts | 6 ++--- src/views/details.ts | 21 ++++++++++++++- 7 files changed, 102 insertions(+), 20 deletions(-) diff --git a/.changeset/v1.md b/.changeset/v1.md index 642a579..31b8e86 100644 --- a/.changeset/v1.md +++ b/.changeset/v1.md @@ -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`. diff --git a/src/__tests__/Buffers.test.ts b/src/__tests__/Buffers.test.ts index 1582534..433560b 100644 --- a/src/__tests__/Buffers.test.ts +++ b/src/__tests__/Buffers.test.ts @@ -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', @@ -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', @@ -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([ diff --git a/src/__tests__/Details.test.ts b/src/__tests__/Details.test.ts index 93405a3..00640b0 100644 --- a/src/__tests__/Details.test.ts +++ b/src/__tests__/Details.test.ts @@ -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( diff --git a/src/__tests__/TextRules.test.ts b/src/__tests__/TextRules.test.ts index 0d590e0..71688da 100644 --- a/src/__tests__/TextRules.test.ts +++ b/src/__tests__/TextRules.test.ts @@ -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' + @@ -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', () => { @@ -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(); }); }); diff --git a/src/catalog/catalog.ts b/src/catalog/catalog.ts index 9960d4e..606a6b4 100644 --- a/src/catalog/catalog.ts +++ b/src/catalog/catalog.ts @@ -20,6 +20,9 @@ import { vfApexCallText, wfFormulaAfter, } from './text.js'; + +export { splitTrailingFields } from './text.js'; + import type { DebugCategory, EventType, @@ -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; } /** diff --git a/src/catalog/text.ts b/src/catalog/text.ts index cdc6080..4b3dcfc 100644 --- a/src/catalog/text.ts +++ b/src/catalog/text.ts @@ -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); @@ -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); diff --git a/src/views/details.ts b/src/views/details.ts index 542beb0..f961554 100644 --- a/src/views/details.ts +++ b/src/views/details.ts @@ -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'; @@ -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. */ @@ -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,