Skip to content
Draft
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- 🧭 **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom. ([#113] [#373] [#63])
- πŸ”¬ **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Needs Apex Code at **FINEST**. ([#373])
- πŸ”­ **Timeline window**: zoom the Timeline and the Inspector summary follows the stretch of log on screen; CPU and heap stay whole-log, since the log reports them only in total. ([#875])
- 🧠 **Heap analysis**: every method and call path reports heap three ways - **Net** (retained), **Gross** (allocated) and **Peak** (highest live) - so allocate-then-free churn no longer looks like a leak. ([#32])
- πŸ—„οΈ **Database governor limits**: SOQL, SOSL, DML and row counts show as `used / limit`, flagging queries that did not consume the limit, plus a dedicated SOSL table. ([#162])
- πŸ”΄ **Timeline exception markers**: exceptions show as red lines, with a Throws count in method tooltips. ([#828])
Expand Down Expand Up @@ -545,6 +546,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
[#373]: https://github.com/certinia/debug-log-analyzer/issues/373
[#298]: https://github.com/certinia/debug-log-analyzer/issues/298
[#162]: https://github.com/certinia/debug-log-analyzer/issues/162
[#875]: https://github.com/certinia/debug-log-analyzer/issues/875
[#113]: https://github.com/certinia/debug-log-analyzer/issues/113
[#63]: https://github.com/certinia/debug-log-analyzer/issues/63
[#32]: https://github.com/certinia/debug-log-analyzer/issues/32
Expand Down
32 changes: 25 additions & 7 deletions log-viewer/src/components/CategoryTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,25 @@ import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

import { logContext } from '../core/log/logContext.js';
import { WindowStatsController } from '../core/log/windowStats.js';
import type { LogStore } from '../core/log/LogStore.js';
import { globalStyles } from '../styles/global.styles.js';
import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js';
import { CategoryPaletteController, categorySelfTimes } from './categoryTime.js';
import { CategoryPaletteController, categorySelfTimes, toCategoryTimes } from './categoryTime.js';
import './StackedTimeBar.js';

/**
* The whole log's self time split by category, as one stacked bar in the flame
* chart's own palette β€” the Inspector's answer to Chrome DevTools' Summary
* donut. Self time, so every nanosecond lands in exactly one segment and the bar
* always totals the log.
* Self time split by category, as one stacked bar in the flame chart's own
* palette. Self time, so every nanosecond lands in exactly one segment and the
* bar always totals its scope.
*
* The scope is the window the Timeline is showing, or the whole log when it
* shows all of it.
*/
@customElement('category-time-bar')
export class CategoryTimeBar extends LitElement {
private readonly _palette = new CategoryPaletteController(this);
private readonly _window = new WindowStatsController(this, () => this.logStore?.log ?? null);

/** The log on screen, from the app root. */
@consume({ context: logContext, subscribe: true })
Expand All @@ -30,10 +34,24 @@ export class CategoryTimeBar extends LitElement {
static styles = [globalStyles, inspectorSectionStyles];

render() {
if (this._window.pending) {
return html`<p class="note">Adding up the self time…</p>`;
}
const apexLog = this.logStore?.log;
const slices = apexLog ? categorySelfTimes(apexLog) : [];
const windowed = this._window.stats;
const slices = windowed
? toCategoryTimes(windowed.selfByCategory)
: apexLog
? categorySelfTimes(apexLog)
: [];
if (!slices.length) {
return html`<p class="note">No categorised time was recorded in this log.</p>`;
return html`<p class="note">
${
this._window.window
? 'No categorised time was recorded in this range.'
: 'No categorised time was recorded in this log.'
}
</p>`;
}

return html`<stacked-time-bar
Expand Down
16 changes: 16 additions & 0 deletions log-viewer/src/components/GovernorTrends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { customElement, property, state } from 'lit/decorators.js';

import { eventBus } from '../core/events/EventBus.js';
import { logContext } from '../core/log/logContext.js';
import { RangeScopeController } from '../core/log/rangeScope.js';
import type { LogStore } from '../core/log/LogStore.js';
import { formatDuration } from '../core/utility/Util.js';
import {
Expand Down Expand Up @@ -114,6 +115,8 @@ export class GovernorTrends extends LitElement {
@property({ attribute: false })
logStore: LogStore | null = null;

private readonly _range = new RangeScopeController(this);

static styles = [
globalStyles,
inspectorSectionStyles,
Expand Down Expand Up @@ -222,6 +225,11 @@ export class GovernorTrends extends LitElement {
stroke-width: 1;
vector-effect: non-scaling-stroke;
}

.trend__window {
fill: currentColor;
opacity: 0.12;
}
`,
];

Expand All @@ -245,6 +253,9 @@ export class GovernorTrends extends LitElement {
const { line, area, guideY, x } = trendGeometry(series, logTotal);
const cursor = this._cursorFor(series);
const cursorX = cursor ? x(cursor.t).toFixed(2) : null;
// The whole log stays on screen and the window is marked on it: readings are
// sparse, so a chart clipped to a short window would draw nothing.
const window = this._range.window;

return html`<div class="trend trend--${governorTier(series.finalRatio)}">
<div class="trend__head">
Expand Down Expand Up @@ -273,6 +284,11 @@ export class GovernorTrends extends LitElement {
aria-hidden="true"
>
${svg`
${
window
? svg`<rect class="trend__window" x=${x(window.start).toFixed(2)} y="0" width=${(x(window.end) - x(window.start)).toFixed(2)} height=${VIEW_H}></rect>`
: ''
}
<path class="trend__area" d=${area}></path>
<path class="trend__line" d=${line}></path>
<line class="trend__guide" x1="0" y1=${guideY} x2=${VIEW_W} y2=${guideY}></line>
Expand Down
70 changes: 67 additions & 3 deletions log-viewer/src/components/LogInspector.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { consume } from '@lit/context';
import { LitElement, css, html, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

Expand All @@ -11,6 +12,9 @@ import {
type SelectionView,
eventBus,
} from '../core/events/EventBus.js';
import { logContext } from '../core/log/logContext.js';
import type { LogStore } from '../core/log/LogStore.js';
import { RangeScopeController, type TimeWindow } from '../core/log/rangeScope.js';
import type { InspectorLocateEvent, InspectorRevealEvent } from './inspectorReveal.js';
import { debounce } from '../core/utility/Util.js';
import { getSettings, updateSetting } from '../features/settings/Settings.js';
Expand All @@ -31,6 +35,20 @@ const SCOPE_OPTIONS: readonly ViewModeOption[] = [
{ value: 'log', label: 'Summary' },
];

/**
* The window the summary is reading, as elapsed times, so it reads against the
* timeline's own axis. The times say the scope on their own, so nothing labels
* them further.
*/
function windowLabel(window: TimeWindow, logStart: number): string {
// Enough decimals to keep the two ends apart: a seek window is 2% of the log
// and a deep zoom a few milliseconds, where "0.30s to 0.30s" says nothing.
const width = Math.max(window.end - window.start, 1) / 1_000_000_000;
const decimals = Math.min(6, Math.max(2, 2 - Math.floor(Math.log10(width))));
const seconds = (at: number) => ((at - logStart) / 1_000_000_000).toFixed(decimals);
return `${seconds(window.start)}s to ${seconds(window.end)}s`;
}

/**
* The app-wide inspector. Lives at the app root (sibling of the tab strip,
* via a forwarded `main` slot) so it crosscuts every tab. It follows the active
Expand Down Expand Up @@ -134,12 +152,37 @@ export class LogInspector extends LitElement {
// the row without the table noticing.
this._clearLocate();
void this._rebuild();
return;
}
// A window appearing or going changes which sections are shown; a viewport
// moving inside one does not, because each section reads the window itself.
if ((this._range.window !== null) !== this._builtWithWindow) {
this._scheduleRebuild();
}
}

/** The log on screen, so a window's times read as elapsed. */
@consume({ context: logContext, subscribe: true })
@property({ attribute: false })
logStore: LogStore | null = null;

private readonly _range = new RangeScopeController(this);

/** Whether the last build had a window. Only its appearing or going changes
* what the sections are, so a moving viewport rebuilds nothing. */
private _builtWithWindow = false;

static styles = [
globalStyles,
css`
.scope-window {
font-family: var(--lana-font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--lana-text-meta);
color: var(--lana-fg-muted);
white-space: nowrap;
}

:host {
display: flex;
flex: 1 1 auto;
Expand Down Expand Up @@ -184,23 +227,42 @@ export class LogInspector extends LitElement {
`;
}

/** Only with a selection to switch away from: one live choice is noise. */
/**
* Only with a selection to switch away from: one live choice is noise. With no
* selection the window still has to be named, so the times stand alone.
*/
private _scopeSwitch() {
const source = this._activeSource;
const label = this._windowLabel();
if (!source || !this._selections.has(source)) {
return '';
return label ? html`<span slot="actions-start" class="scope-window">${label}</span>` : '';
}
// The summary reads the window when there is one, so its own label says so.
const options = label
? SCOPE_OPTIONS.map((option) => (option.value === 'log' ? { ...option, label } : option))
: SCOPE_OPTIONS;
return html`<view-mode-switch
slot="actions-start"
aria-label="Inspector scope"
title="Read what you selected, or this tab's summary of the whole log"
.options=${SCOPE_OPTIONS}
.options=${options}
value=${this._scope}
@view-mode-change=${(e: CustomEvent<{ value: string }>) =>
this._setScope(e.detail.value as InspectorScope)}
></view-mode-switch>`;
}

/** The window's times, or null where the summary reads the whole log. Only the
* Timeline scopes to a window, so only its own tab names one. */
private _windowLabel(): string | null {
const window = this._range.window;
const log = this.logStore?.log;
if (!window || !log || this._activeSource !== 'timeline') {
return null;
}
return windowLabel(window, log.timestamp);
}

private get _activeSource(): DetailSource | undefined {
return TAB_TO_SOURCE[this.activeTab];
}
Expand Down Expand Up @@ -342,13 +404,15 @@ export class LogInspector extends LitElement {

private async _rebuild(): Promise<void> {
const epoch = ++this._rebuildEpoch;
this._builtWithWindow = this._range.window !== null;
const source = this._activeSource;
const sections = source
? await buildDetailSections(
source,
this._scopedSelection(source),
this._active.get(source) ?? null,
this._sourceViews.get(source),
this._range.window,
)
: [];
// Drop a slow build that a newer selection already superseded.
Expand Down
25 changes: 21 additions & 4 deletions log-viewer/src/components/LogOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

import { logContext } from '../core/log/logContext.js';
import { WindowStatsController } from '../core/log/windowStats.js';
import type { LogStore } from '../core/log/LogStore.js';
import { apexLimitTimeSeries } from '../features/timeline/optimised/apex-limit-series.js';
import { globalStyles } from '../styles/global.styles.js';
Expand All @@ -19,9 +20,14 @@ import {
import '../features/database/components/GovernorSummary.js';

/**
* The inspector's whole-log section, shown while nothing is selected: the
* governor metrics nearest a limit, read from the metric strip's series so the
* figures always match the timeline and the trend charts.
* The inspector's unselected section: the governor metrics nearest a limit,
* read from the metric strip's series so the figures always match the timeline
* and the trend charts.
*
* On the Timeline tab, while the chart shows part of the log, the statements are
* counted for that window instead, from the events themselves. CPU time and heap
* have no windowed value and stay whole-log. Every other tab passes `wholeLog`,
* since a window is a Timeline idea.
*
* Log size and duration are deliberately absent β€” `LogMeta` heads the app with
* both.
Expand All @@ -33,6 +39,13 @@ export class LogOverview extends LitElement {
@property({ attribute: false })
logStore: LogStore | null = null;

/** True keeps the whole-log figures whatever the Timeline is showing. The
* window is a Timeline idea, and every other tab scopes by its own row. */
@property({ type: Boolean })
wholeLog = false;

private readonly _window = new WindowStatsController(this, () => this.logStore?.log ?? null);

static styles = [
globalStyles,
css`
Expand All @@ -54,7 +67,11 @@ export class LogOverview extends LitElement {

render() {
const apexLog = this.logStore?.log;
const gauges = apexLog ? seriesGauges(apexLimitTimeSeries(apexLog)) : [];
if (!this.wholeLog && this._window.pending) {
return html`<p class="note">Adding up the governor usage…</p>`;
}
const window = this.wholeLog ? null : this._window.counts;
const gauges = apexLog ? seriesGauges(apexLimitTimeSeries(apexLog), window ?? undefined) : [];
if (!apexLog || !gauges.length) {
return html`<p class="note">${NO_CUMULATIVE_LIMITS_TEXT}</p>`;
}
Expand Down
Loading
Loading