Skip to content
Open
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
4 changes: 1 addition & 3 deletions log-viewer/src/features/app/LogViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,7 @@ export class LogViewer extends LitElement {
this.logSize = apexLog.size;
this.timelineRoot = apexLog;
this.logDuration = apexLog.duration.total;
// Raw text is needed for the user: USER_INFO precedes EXECUTION_STARTED, so the
// parser never sees it. See deriveLogIdentity.
this.logIdentity = deriveLogIdentity(apexLog, logData);
this.logIdentity = deriveLogIdentity(apexLog);

// Rebuilt per load, never appended to: it describes *this* log, so a previous
// log's problems must not carry over.
Expand Down
28 changes: 19 additions & 9 deletions log-viewer/src/features/app/__tests__/logIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function transaction(codeUnitStarted: string, prefix = USER_INFO_LINE): string {
}

function identity(rawLog: string) {
return deriveLogIdentity(parse(rawLog), rawLog);
return deriveLogIdentity(parse(rawLog));
}

describe('entry point', () => {
Expand Down Expand Up @@ -80,18 +80,28 @@ describe('user and start time', () => {
);
});

it('finds USER_INFO past a preamble longer than any fixed scan window', () => {
const preamble =
'64.0 APEX_CODE,FINE;APEX_PROFILING,INFO\n' +
`Execute Anonymous: ${'x'.repeat(8192)}\n` +
'Execute Anonymous: quoting |USER_INFO| inside the echo must not match\n';
it('keeps the offset when the header names no timezone', () => {
const log = transaction(
anonymous,
'09:18:22.6 (6297619)|USER_INFO|[EXTERNAL]|005Ea00000R6orz|tina.owen@example.com|(GMT+05:30)|GMT+05:30\n',
);

const { user } = identity(transaction(anonymous, preamble + USER_INFO_LINE));
expect(identity(log).startTime?.detail).toMatch(/^Started 09:18:22\S* \(GMT\+05:30\)$/);
});

expect(user).toEqual({ label: 'tina.owen', detail: 'tina.owen@example.com' });
it('omits the user when USER_INFO states no name, keeping its timezone', () => {
const log = transaction(
anonymous,
'09:18:22.6 (6297619)|USER_INFO|[EXTERNAL]|005Ea00000R6orz||(GMT-07:00) Pacific Daylight Time (America/Los_Angeles)|GMT-07:00\n',
);

const { user, startTime } = identity(log);

expect(user).toBeNull();
expect(startTime?.detail).toContain('Pacific Daylight Time');
});

it('omits the user in a cropped log whose first event is not USER_INFO', () => {
it('omits the user when the log states no USER_INFO', () => {
const { user, startTime } = identity(transaction(anonymous, ''));

expect(user).toBeNull();
Expand Down
92 changes: 26 additions & 66 deletions log-viewer/src/features/app/logIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import {
CodeUnitStartedLine,
ExecutionStartedLine,
type ApexLog,
} from '@apexdevtools/apex-log-parser';
import type { ApexLog, CodeUnitStartedLine } from '@apexdevtools/apex-log-parser';
import type { LogTimezone } from '@apexdevtools/apex-log-parser/types';

import { formatWallClockTime } from '../../core/utility/Util.js';

Expand All @@ -22,50 +19,21 @@ export interface LogIdentityData {
startTime: LogIdentityItem | null;
}

/**
* Derives the header identity from a parsed log. `rawLog` is needed for the user:
* parsing starts at `EXECUTION_STARTED`, so the `USER_INFO` line before it never
* becomes an event. TODO(spike): hoist USER_INFO onto `ApexLog` in the parser and
* drop the raw-text scan.
*/
export function deriveLogIdentity(log: ApexLog, rawLog: string): LogIdentityData {
const userInfo = parseUserInfo(rawLog);
/** Derives the header identity from a parsed log. */
export function deriveLogIdentity(log: ApexLog): LogIdentityData {
const { entryPoint, userInfo } = log;
// A header line can state no name at all. Keyed on the name, not the line, so the
// header drops the chunk rather than showing a separator around an empty item.
const userName = userInfo?.userName;
return {
entryPoint: entryPointItem(log),
user: userInfo
? { label: userInfo.username.split('@')[0] || userInfo.username, detail: userInfo.username }
: null,
entryPoint: entryPoint ? { label: entryPointLabel(entryPoint), detail: entryPoint.text } : null,
user: userName ? { label: userName.split('@')[0] || userName, detail: userName } : null,
// The timezone sits with the time, not the user: the log's timestamps are
// rendered in that zone, so it qualifies the clock reading.
startTime: startTimeItem(log, userInfo?.timezone),
startTime: startTimeItem(log, userInfo ? formatTimezone(userInfo.timezone) : ''),
};
}

function entryPointItem(log: ApexLog): LogIdentityItem | null {
const unit = firstCodeUnit(log);
return unit ? { label: entryPointLabel(unit), detail: unit.text } : null;
}

/**
* The first `CODE_UNIT_STARTED` stands in for the request's operation, the same
* field Salesforce's own debug-log list calls "Operation". It is usually nested
* under `EXECUTION_STARTED`, but sits at the root when that marker is absent.
*/
function firstCodeUnit(log: ApexLog): CodeUnitStartedLine | null {
for (const child of log.children) {
if (child instanceof CodeUnitStartedLine) {
return child;
}
if (child instanceof ExecutionStartedLine) {
const unit = child.children.find((c) => c instanceof CodeUnitStartedLine);
if (unit) {
return unit;
}
}
}
return null;
}

function entryPointLabel(unit: CodeUnitStartedLine): string {
const text = unit.text;
if (text === 'execute_anonymous_apex') {
Expand All @@ -88,32 +56,24 @@ function entryPointLabel(unit: CodeUnitStartedLine): string {
}
}

/** Matches an event line's `HH:MM:SS.f (elapsedNs)|` prefix. */
const TIMESTAMPED_LINE = /^\d{2}:\d{2}:\d{2}\.\d+ \(\d+\)\|/;
/**
* Rebuilds the header's `(GMTΒ±HH:MM) Label (IANA/Name)` wording from the parts the parser splits
* it into. The offset reproduces exactly, but a log stating a bare label gains a prefix it never
* carried, since nothing in `LogTimezone` says whether one was there. apex-log-parser#85 asks for
* the source text, which would replace this.
*/
function formatTimezone({ label, name, offsetMinutes }: LogTimezone): string {
const offset = offsetMinutes === null ? '' : `(GMT${gmtOffset(offsetMinutes)})`;
return [offset, label, name ? `(${name})` : ''].filter(Boolean).join(' ');
}

function parseUserInfo(rawLog: string): { username: string; timezone: string } | null {
// USER_INFO is the first *timestamped* line; only the untimestamped preamble
// (version header, `Execute Anonymous:` echoes) precedes it. Walking lines and
// stopping at the first event keeps the cost at the preamble's size, survives a
// preamble of any length, and can't match a `USER_INFO` quoted inside the echoes
// or some later event's payload. A cropped log bails at its first event line.
let start = 0;
while (start < rawLog.length) {
const nl = rawLog.indexOf('\n', start);
const eol = nl === -1 ? rawLog.length : nl;
const line = rawLog.slice(start, eol);
if (TIMESTAMPED_LINE.test(line)) {
// timestamp|USER_INFO|[EXTERNAL]|userId|username|timezone label|timezone offset
const parts = line.split('|');
const username = parts[1] === 'USER_INFO' ? (parts[4]?.trim() ?? '') : '';
return username ? { username, timezone: parts[5]?.trim() ?? '' } : null;
}
start = eol + 1;
}
return null;
function gmtOffset(offsetMinutes: number): string {
const pad = (value: number): string => String(value).padStart(2, '0');
const absolute = Math.abs(offsetMinutes);
return `${offsetMinutes < 0 ? '-' : '+'}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
}

function startTimeItem(log: ApexLog, timezone?: string): LogIdentityItem | null {
function startTimeItem(log: ApexLog, timezone: string): LogIdentityItem | null {
if (log.startTime === null) {
return null;
}
Expand Down
Loading