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
27 changes: 24 additions & 3 deletions apex-log-parser/src/ApexLogParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export class ApexLogParser {
lastTimestamp = 0;
discontinuity = false;
namespaces = new Set<string>();
/** Every event created during this parse, indexed by `LogEvent.eventIndex`. */
eventsById: LogEvent[] = [];
governorLimits: GovernorLimits = {
soqlQueries: { used: 0, limit: 0 },
soslQueries: { used: 0, limit: 0 },
Expand Down Expand Up @@ -66,6 +68,7 @@ export class ApexLogParser {
apexLog.parsingErrors = this.parsingErrors;
apexLog.namespaces = Array.from(this.namespaces);
apexLog.governorLimits = this.governorLimits;
apexLog.eventsById = this.eventsById;

this.addGovernorLimits(apexLog);

Expand Down Expand Up @@ -118,13 +121,15 @@ export class ApexLogParser {
} else if (lastEntry && line.startsWith('*** Skipped')) {
this.addLogIssue(
lastEntry.timestamp,
lastEntry.eventIndex,
'Skipped-Lines',
`${line}. A section of the log has been skipped and the log has been truncated. Full details of this section of log can not be provided.`,
'skip',
);
} else if (lastEntry && line.indexOf('MAXIMUM DEBUG LOG SIZE REACHED') !== -1) {
this.addLogIssue(
lastEntry.timestamp,
lastEntry.eventIndex,
'Max-Size-reached',
'The maximum log size has been reached. Part of the log has been truncated.',
'skip',
Expand Down Expand Up @@ -265,6 +270,7 @@ export class ApexLogParser {
// we found an entry event on its own e.g a `METHOD_ENTRY` without a `METHOD_EXIT` and got to the end of the log
this.addLogIssue(
currentLine.exitStamp,
currentLine.eventIndex,
'Unexpected-End',
'An entry event was found without a corresponding exit event e.g a `METHOD_ENTRY` event without a `METHOD_EXIT`',
'unexpected',
Expand All @@ -273,6 +279,7 @@ export class ApexLogParser {
if (currentLine.isTruncated) {
this.updateLogIssue(
currentLine.exitStamp,
currentLine.eventIndex,
'Max-Size-reached',
'The maximum log size has been reached. Part of the log has been truncated.',
'skip',
Expand Down Expand Up @@ -319,6 +326,7 @@ export class ApexLogParser {
// we found an exit event on its own e.g a `METHOD_EXIT` without a `METHOD_ENTRY`
this.addLogIssue(
endLine.timestamp,
endLine.eventIndex,
'Unexpected-Exit',
'An exit event was found without a corresponding entry event e.g a `METHOD_EXIT` event without a `METHOD_ENTRY`',
'unexpected',
Expand Down Expand Up @@ -468,11 +476,18 @@ export class ApexLogParser {
}
}

public addLogIssue(startTime: number, summary: string, description: string, type: IssueType) {
public addLogIssue(
startTime: number,
eventIndex: number | undefined,
summary: string,
description: string,
type: IssueType,
) {
if (!this.reasons.has(summary)) {
this.reasons.add(summary);
this.logIssues.push({
startTime: startTime,
eventIndex: eventIndex,
summary: summary,
description: description,
type: type,
Expand All @@ -482,7 +497,13 @@ export class ApexLogParser {
}
}

private updateLogIssue(startTime: number, summary: string, description: string, type: IssueType) {
private updateLogIssue(
startTime: number,
eventIndex: number | undefined,
summary: string,
description: string,
type: IssueType,
) {
const elem = this.logIssues.findIndex((item) => {
return item.summary === summary;
});
Expand All @@ -491,7 +512,7 @@ export class ApexLogParser {
}
this.reasons.delete(summary);

this.addLogIssue(startTime, summary, description, type);
this.addLogIssue(startTime, eventIndex, summary, description, type);
}

private getDebugLevels(log: string): DebugLevel[] {
Expand Down
26 changes: 24 additions & 2 deletions apex-log-parser/src/LogEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ export abstract class LogEvent {
*/
timestamp = 0;

/**
* A globally-unique, monotonically-increasing index assigned at construction
* time within a single parse. Use this as a stable identifier when navigating
* between views — unlike `timestamp`, it is guaranteed unique per event.
*/
eventIndex = 0;

/**
* The timestamp when the node finished, in nanoseconds
*/
Expand Down Expand Up @@ -229,6 +236,8 @@ export abstract class LogEvent {

constructor(parser: ApexLogParser, parts: string[]) {
this.logParser = parser;
this.eventIndex = parser.eventsById.length;
parser.eventsById.push(this);
const [timeData, type] = parts;
if (type) {
this.text = this.type = type as LogEventType;
Expand Down Expand Up @@ -360,6 +369,13 @@ export class ApexLog extends LogEvent {
*/
startTime: number | null = null;

/**
* Flat lookup of every parsed event by its `eventIndex`. Populated by the
* parser; used by cross-view navigation to resolve a stable id back to its
* `LogEvent` without relying on (non-unique) timestamps.
*/
eventsById: LogEvent[] = [];

/**
* The endtime with nodes of 0 duration excluded
*/
Expand Down Expand Up @@ -2284,7 +2300,7 @@ export class ExceptionThrownLine extends LogEvent {
const truncateText = this.text.length > len;
const summary = this.text.slice(0, len + 1) + (truncateText ? '…' : '');
const message = truncateText ? this.text : '';
parser.addLogIssue(this.timestamp, summary, message, 'error');
parser.addLogIssue(this.timestamp, this.eventIndex, summary, message, 'error');
}
}
}
Expand All @@ -2304,7 +2320,13 @@ export class FatalErrorLine extends LogEvent {
const newLineIndex = this.text.indexOf('\n');
const summary = newLineIndex > -1 ? this.text.slice(0, newLineIndex + 1) : this.text;
const detailText = summary.length !== this.text.length ? this.text : '';
parser.addLogIssue(this.timestamp, 'FATAL ERROR! cause=' + summary, detailText, 'error');
parser.addLogIssue(
this.timestamp,
this.eventIndex,
'FATAL ERROR! cause=' + summary,
detailText,
'error',
);
}
}

Expand Down
1 change: 1 addition & 0 deletions apex-log-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface GovernorLimits extends Limits {

export interface LogIssue {
startTime?: number;
eventIndex?: number;
summary: string;
description: string;
type: IssueType;
Expand Down
20 changes: 20 additions & 0 deletions log-viewer/src/__tests__/Database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,24 @@ describe('Analyse database tests', () => {
const firstDML = result.getDMLLines()[0];
expect(firstDML?.text).toEqual('DML Op:Insert Type:codaCompany__c');
});

it('resolves stack by eventIndex when timestamps are duplicated', async () => {
const log =
'09:18:22.6 (6574780)|EXECUTION_STARTED\n' +
'09:18:22.6 (6586704)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' +
'09:18:22.6 (7000000)|METHOD_ENTRY|[1]|01p|ns.ClassOne.first()\n' +
'09:18:22.6 (7100000)|METHOD_EXIT|[1]|ns.ClassOne.first()\n' +
'09:18:22.6 (7000000)|METHOD_ENTRY|[2]|01p|ns.ClassTwo.second()\n' +
'09:18:22.6 (7200000)|METHOD_EXIT|[2]|ns.ClassTwo.second()\n' +
'09:18:22.6 (7300000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' +
'09:18:22.6 (7400000)|EXECUTION_FINISHED\n';

const apexLog = parse(log);
const result = await DatabaseAccess.create(apexLog);
const methodTwo = apexLog.eventsById.find((evt) => evt.text === 'ns.ClassTwo.second()');

expect(methodTwo).toBeDefined();
const stack = result.getStackByEventIndex(methodTwo!.eventIndex);
expect(stack[stack.length - 1]?.text).toBe('ns.ClassTwo.second()');
});
});
30 changes: 30 additions & 0 deletions log-viewer/src/__tests__/EventSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { describe, expect, it } from '@jest/globals';
import { parse } from 'apex-log-parser';

import { findEventByEventIndex } from '../core/utility/EventSearch.js';

describe('EventSearch', () => {
it('finds the exact event by eventIndex when timestamps are duplicated', () => {
const log =
'09:18:22.6 (6574780)|EXECUTION_STARTED\n' +
'09:18:22.6 (6586704)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' +
'09:18:22.6 (7000000)|METHOD_ENTRY|[1]|01p|ns.ClassOne.first()\n' +
'09:18:22.6 (7100000)|METHOD_EXIT|[1]|ns.ClassOne.first()\n' +
'09:18:22.6 (7000000)|METHOD_ENTRY|[2]|01p|ns.ClassTwo.second()\n' +
'09:18:22.6 (7200000)|METHOD_EXIT|[2]|ns.ClassTwo.second()\n' +
'09:18:22.6 (7300000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' +
'09:18:22.6 (7400000)|EXECUTION_FINISHED\n';

const apexLog = parse(log);
const target = apexLog.eventsById.find((evt) => evt.text === 'ns.ClassTwo.second()');

expect(target).toBeDefined();
const result = findEventByEventIndex(apexLog, target!.eventIndex);

expect(result?.event.text).toBe('ns.ClassTwo.second()');
expect(result?.event.eventIndex).toBe(target!.eventIndex);
});
});
21 changes: 13 additions & 8 deletions log-viewer/src/components/CallStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { globalStyles } from '../styles/global.styles.js';
@customElement('call-stack')
export class CallStack extends LitElement {
@property({ type: Number })
timestamp = -1;
eventIndex = -1;
@property({ type: Number })
startDepth = 1;
@property({ type: Number })
Expand Down Expand Up @@ -79,11 +79,14 @@ export class CallStack extends LitElement {
// 1. THE PERFORMANCE ENGINE: Process data BEFORE rendering
protected willUpdate(changedProperties: PropertyValues) {
if (
changedProperties.has('timestamp') ||
changedProperties.has('eventIndex') ||
changedProperties.has('startDepth') ||
changedProperties.has('endDepth')
) {
const stack = DatabaseAccess.instance()?.getStack(this.timestamp).reverse() ?? [];
const stack =
this.eventIndex >= 0
? (DatabaseAccess.instance()?.getStackByEventIndex(this.eventIndex).reverse() ?? [])
: [];

if (stack.length > 0) {
// Run the heavy loop and formatting logic here, only when inputs change
Expand Down Expand Up @@ -126,7 +129,7 @@ export class CallStack extends LitElement {
return html`<a
@click=${this.onCallerClick}
class="callstack__item code_text"
data-timestamp="${line.timestamp}"
data-event-index="${line.eventIndex}"
>${soqlBlock}</a
>`;
}
Expand All @@ -143,10 +146,12 @@ export class CallStack extends LitElement {

evt.stopPropagation();
evt.preventDefault();
const target = evt.target as HTMLElement;
const dataTimestamp = target.getAttribute('data-timestamp');
if (dataTimestamp) {
goToRow(parseInt(dataTimestamp));
const target = (evt.target as HTMLElement).closest('.callstack__item');
const dataEventIndex = target?.getAttribute('data-event-index');
if (!dataEventIndex) {
return;
}

goToRow({ eventIndex: parseInt(dataEventIndex, 10) });
}
}
28 changes: 16 additions & 12 deletions log-viewer/src/components/LogProblems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,21 @@ export class NotificationTag extends LitElement {
sortedNotifications.forEach((item, index) => {
const colorStyle = this.colorStyles.get(item.severity) || '';

const buttonBar = item.timestamp
? html`<div class="button-bar">
<vscode-button
aria-label="Go To Call Tree"
title="Go To Call Tree"
@click=${() => {
goToRow(item.timestamp ?? 0);
}}
>Go To Call Tree</vscode-button
>
</div>`
: '';
const buttonBar =
item.eventIndex !== null
? html`<div class="button-bar">
<vscode-button
aria-label="Go To Call Tree"
title="Go To Call Tree"
@click=${() => {
if (item.eventIndex !== null) {
goToRow({ eventIndex: item.eventIndex });
}
}}
>Go To Call Tree</vscode-button
>
</div>`
: '';

const content = html`<div class="text-container">
${item.message
Expand Down Expand Up @@ -198,5 +201,6 @@ export class LogProblem {
summary = '';
message = '';
severity: 'Error' | 'Warning' | 'Info' | 'none' = 'none';
eventIndex: number | null = null;
timestamp: number | null = null;
}
6 changes: 5 additions & 1 deletion log-viewer/src/core/events/EventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
*/

interface EventMap {
'timeline:navigate-to': { timestamp: number };
// Supply eventIndex (preferred — unique) OR timestamp (fallback for raw-log entry where eventIndex isn't known).
// eslint-disable-next-line @typescript-eslint/naming-convention
'timeline:navigate-to':
| { eventIndex: number; timestamp?: never }
| { eventIndex?: never; timestamp: number };
}

type EventCallback<K extends keyof EventMap> = (detail: EventMap[K]) => void;
Expand Down
25 changes: 24 additions & 1 deletion log-viewer/src/core/utility/EventSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) 2024 Certinia Inc. All rights reserved.
*/

import type { LogEvent } from 'apex-log-parser';
import type { ApexLog, LogEvent } from 'apex-log-parser';

export interface EventSearchResult {
event: LogEvent;
Expand Down Expand Up @@ -58,3 +58,26 @@ export function findEventByTimestamp(

return null;
}

/**
* Resolve an event directly by its parser-assigned eventIndex.
* This is stable and unique within a single parse.
*/
export function findEventByEventIndex(
apexLog: ApexLog,
eventIndex: number,
): EventSearchResult | null {
const event = apexLog.eventsById[eventIndex];
if (!event) {
return null;
}

let depth = 0;
let parent = event.parent;
while (parent?.parent) {
depth++;
parent = parent.parent;
}

return { event, depth };
}
Loading
Loading