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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 🧭 **Inspector**: select anything β€” a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement β€” and inspect it without leaving the tab you're on. ([#113])
- **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it β€” the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it.
- **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, every call path that ends in a query, DML or search with total and self time, and how few statements hold the time. ([#373])
- Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view β€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Right-click for copy actions.
- Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view β€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions.
- **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called.
- **Detail | Summary** switches between what you picked and the tab's summary of the whole log, keeping the selection to come back to.
- Dock it left, right or bottom, drag to resize any section β€” double-click a divider to restore the defaults β€” and collapse the sections you don't need; the layout is remembered. `Escape` clears the selection and returns the whole-log view. ([#63])
Expand Down
2 changes: 1 addition & 1 deletion lana-docs/docs/docs/features/inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Press **Summary** in the panel's header to read the whole log without giving up

With nothing selected the inspector reads the whole log. Every tab opens with an **Overview** β€” the six governor metrics closest to their limit β€” then adds what its own tab can answer at log scope:

- **Timeline** – time by category, **self time by namespace**, governor usage over time, and the whole-log call tree.
- **Timeline** – time by category, **self time by namespace**, governor usage over time, and the whole-log call tree. Click a point on a usage chart, or step the arrow keys across a focused chart and press `Enter`, to move the Timeline to that instant and zoom in on it. Nothing is selected, so the inspector keeps this whole-log reading.
- **Call Tree** – the **hot path** the log spent its time in, and the **hot spots** with the most self time.
- **Database** – **Namespace duration**: **Called from namespace** β€” the namespace that issued the statement β€” and, when they differ, **Ran in namespace**, the namespaces of whatever ran beneath it, such as a package trigger firing on your DML. **Call tree**: every call path that ends in a query, DML or search, with **Total Time** β€” the database time at or below the row β€” beside **Self Time**, the row's own code. A row with all total and no self is waiting on the database; the reverse is the Apex around it. **Database duration**: how few statements hold the time, with cost per row, how often each ran, and its duration split into self time and descendants, so a DML that is cheap in itself but fires seven seconds of triggers reads as one.
- **Analysis** – **Findings**: what is slow or wrong in the log, and what to do about it, led by the findings by severity β€” press any number of them to hold the list to those. A finding whose events the log times also shows how long they took and what that is of the log. Each finding lists the statements behind it, most repeated first; click one to reveal its row in the grid.
Expand Down
162 changes: 132 additions & 30 deletions log-viewer/src/components/GovernorTrends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { consume } from '@lit/context';
import { LitElement, css, html, svg } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

import { eventBus } from '../core/events/EventBus.js';
import { logContext } from '../core/log/logContext.js';
import type { LogStore } from '../core/log/LogStore.js';
import { formatDuration } from '../core/utility/Util.js';
Expand All @@ -13,6 +14,7 @@ import {
governorTier,
} from '../features/database/components/GovernorSummary.js';
import { apexLimitTimeSeries } from '../features/timeline/optimised/apex-limit-series.js';
import { SEEK_LOG_SHARE } from '../features/timeline/utils/navigate-window.js';
import { globalStyles } from '../styles/global.styles.js';
import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js';
import {
Expand All @@ -27,6 +29,11 @@ import { NO_CUMULATIVE_LIMITS_TEXT } from './logOverviewMetrics.js';
const VIEW_W = 100;
const VIEW_H = 30;

/** How far one arrow key moves the cursor: the seek window's own share of the
* log, so successive steps sweep the log without leaving a gap between the
* windows they can reach. */
const KEY_STEP = SEEK_LOG_SHARE;

/** The pieces of a chart that depend only on the data, never the hover. */
interface TrendGeometry {
line: string;
Expand Down Expand Up @@ -74,12 +81,18 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry {
* metrics closest to their limits. Every chart shares the same x-domain (the
* whole log) so shapes are comparable; each y-domain runs to at least 100% so
* a flat safe line reads as safe. Colours follow the gauges' tiers.
*
* A click, or Enter on a focused chart, moves the timeline to that instant and
* zooms in on it, so a spike leads straight to its cause. The chart selects
* nothing, so the inspector keeps its whole-log reading.
*/
@customElement('governor-trends')
export class GovernorTrends extends LitElement {
/** The sample under the pointer, on the one hovered chart. */
/** The sample under the pointer or the arrow keys, on the one chart that holds
* it. `from` says which placed it: a pointer leaving takes its own cursor
* with it, never one the keys placed. */
@state()
private _hover: { label: string; point: TrendPoint } | null = null;
private _cursor: { label: string; point: TrendPoint; from: 'pointer' | 'key' } | null = null;

/** The log on screen, from the app root. */
@consume({ context: logContext, subscribe: true })
Expand Down Expand Up @@ -133,11 +146,28 @@ export class GovernorTrends extends LitElement {
color: var(--lana-fg-muted);
}

/* A button, not the svg itself: Chromium matches :focus-visible on a
click for a focusable svg, so the ring appeared on every seek. */
.trend__chart {
display: block;
width: 100%;
height: 44px;
border: 0;
border-bottom: 1px solid var(--lana-surface-border);
padding: 0;
background: none;
color: inherit;
cursor: pointer;
}

.trend__plot {
display: block;
width: 100%;
height: 44px;
}

.trend__chart:focus-visible {
outline: var(--lana-stroke) solid var(--lana-focus-border);
outline-offset: calc(-1 * var(--lana-stroke));
}

.trend--safe {
Expand Down Expand Up @@ -172,7 +202,7 @@ export class GovernorTrends extends LitElement {

/* A vertical line, not a circle β€” preserveAspectRatio="none" would
distort any shape with area. */
.trend__hover {
.trend__cursor {
stroke: currentColor;
stroke-width: 1;
vector-effect: non-scaling-stroke;
Expand All @@ -198,47 +228,119 @@ export class GovernorTrends extends LitElement {

private _renderTrend(series: TrendSeries, logTotal: number) {
const { line, area, guideY, x } = trendGeometry(series, logTotal);
const hovered = this._hover?.label === series.label ? this._hover.point : null;
const hoverX = hovered ? x(hovered.t).toFixed(2) : null;
const cursor = this._cursorFor(series);
const cursorX = cursor ? x(cursor.t).toFixed(2) : null;

return html`<div class="trend trend--${governorTier(series.finalRatio)}">
<div class="trend__head">
<span class="trend__label">${series.label}</span>
<span class="trend__value"
>${hovered ? html`${formatDuration(hovered.t)} Β· ` : ''}${series.format(
hovered ? hovered.used : series.used,
<span class="trend__value" aria-live="polite"
>${cursor ? html`${formatDuration(cursor.t)} Β· ` : ''}${series.format(
cursor ? cursor.used : series.used,
)} <span class="trend__limit">/ ${series.format(series.limit)}</span></span
>
</div>
<svg
<button
class="trend__chart"
viewBox="0 0 ${VIEW_W} ${VIEW_H}"
preserveAspectRatio="none"
role="img"
aria-label="${series.label}: ${Math.round(series.finalRatio)}% of the limit used"
type="button"
aria-label="${series.label}: ${Math.round(
series.finalRatio,
)}% of the limit used. Move the timeline to a point in the log."
@pointermove=${(event: PointerEvent) => this._onPointerMove(event, series, logTotal)}
@pointerleave=${() => (this._hover = null)}
@pointerleave=${() => this._onPointerLeave()}
@click=${(event: PointerEvent) => this._onClick(event, series, logTotal)}
@keydown=${(event: KeyboardEvent) => this._onKeyDown(event, series, logTotal)}
>
${svg`
<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>
${hoverX === null ? '' : svg`<line class="trend__hover" x1=${hoverX} y1="0" x2=${hoverX} y2=${VIEW_H}></line>`}
`}
</svg>
<svg
class="trend__plot"
viewBox="0 0 ${VIEW_W} ${VIEW_H}"
preserveAspectRatio="none"
aria-hidden="true"
>
${svg`
<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>
${cursorX === null ? '' : svg`<line class="trend__cursor" x1=${cursorX} y1="0" x2=${cursorX} y2=${VIEW_H}></line>`}
`}
</svg>
</button>
</div>`;
}

private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number) {
// currentTarget is the <svg> the handler is bound to; event.target could be
// one of its paths, whose offsetX is useless here.
const rect = (event.currentTarget as SVGSVGElement).getBoundingClientRect();
if (rect.width <= 0 || logTotal <= 0) {
private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number): void {
const point = this._pointFrom(event, series, logTotal);
this._cursor = point ? { label: series.label, point, from: 'pointer' } : null;
}

private _onPointerLeave(): void {
if (this._cursor?.from === 'pointer') {
this._cursor = null;
}
}

private _onClick(event: PointerEvent, series: TrendSeries, logTotal: number): void {
// Where the pointer is wins: the cursor may sit where the arrow keys left it.
const point = this._pointFrom(event, series, logTotal) ?? this._cursorFor(series);
if (point) {
this._seek(point.t);
}
}

/**
* Arrows step the cursor across the log; Enter and Space move the timeline to
* it. With no cursor the last sample answers: consumption never falls inside
* a transaction, so that is where the metric stands highest.
*/
private _onKeyDown(event: KeyboardEvent, series: TrendSeries, logTotal: number): void {
const step =
event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : undefined;
if (step !== undefined) {
const from = this._cursorFor(series)?.t ?? 0;
const t = Math.min(Math.max(from + step * logTotal, 0), logTotal);
const point = pointAt(series.points, t);
if (point) {
this._cursor = { label: series.label, point, from: 'key' };
}
event.preventDefault();
return;
}
const t = ((event.clientX - rect.left) / rect.width) * logTotal;
const point = pointAt(series.points, t);
this._hover = point ? { label: series.label, point } : null;
if (event.key === 'Enter' || event.key === ' ') {
const point = this._cursorFor(series) ?? series.points.at(-1);
if (point) {
this._seek(point.t);
}
event.preventDefault();
}
}

/** The cursor, when this chart is the one holding it. */
private _cursorFor(series: TrendSeries): TrendPoint | null {
return this._cursor?.label === series.label ? this._cursor.point : null;
}

/** The series' value at the pointer, in the log's own time. */
private _pointFrom(
event: PointerEvent,
series: TrendSeries,
logTotal: number,
): TrendPoint | null {
// currentTarget is the button the handler is bound to; event.target could be
// one of the plot's paths, whose offsetX is useless here.
const rect = (event.currentTarget as HTMLButtonElement).getBoundingClientRect();
if (rect.width <= 0 || logTotal <= 0) {
return null;
}
return pointAt(series.points, ((event.clientX - rect.left) / rect.width) * logTotal);
}

/**
* Move the timeline to `t` and zoom to a window of the log around it. Nothing
* is selected: these charts read the whole log, and a selection would swap the
* inspector to one frame's detail.
*/
private _seek(t: number): void {
eventBus.emit('timeline:navigate-to', { timestamp: t, mode: 'seek' });
}
}

Expand Down
140 changes: 140 additions & 0 deletions log-viewer/src/components/__tests__/GovernorTrends.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*
* @jest-environment jsdom
*/
import { beforeEach, describe, expect, it } from '@jest/globals';
import type { LitElement } from 'lit';

import { eventBus } from '../../core/events/EventBus.js';
import type { LogStore } from '../../core/log/LogStore.js';
import type { TrendSeries } from '../governorTrendData.js';

// The charts are driven from one stub series, so the seek is the only logic
// under test. `pointAt` stays real: the click reads the series through it.
let series: TrendSeries[];
jest.mock('../governorTrendData.js', () => ({
...jest.requireActual('../governorTrendData.js'),
governorTrendSeries: () => series,
}));
jest.mock('../../features/timeline/optimised/apex-limit-series.js', () => ({
apexLimitTimeSeries: () => ({ events: [] }),
}));

import '../GovernorTrends.js';

const LOG_NS = 1_000;

const trend = (): TrendSeries => ({
label: 'SOQL queries',
points: [
{ t: 0, ratio: 0, used: 0 },
{ t: 400, ratio: 40, used: 40 },
{ t: 800, ratio: 90, used: 90 },
],
used: 90,
limit: 100,
finalRatio: 90,
format: String,
});

async function mount(): Promise<LitElement> {
const element = document.createElement('governor-trends');
// No provider in the test, so the consumed store is assigned straight on.
(element as unknown as { logStore: LogStore }).logStore = {
log: { duration: { total: LOG_NS } },
} as unknown as LogStore;
document.body.append(element);
await element.updateComplete;
return element;
}

/** The chart, given a width so a pointer x maps to a time. */
function chartOf(element: LitElement): HTMLButtonElement {
const chart = element.shadowRoot!.querySelector('.trend__chart') as HTMLButtonElement;
chart.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 44 }) as DOMRect;
return chart;
}

let seeks: { timestamp?: number; mode?: string }[];
let unsubscribe: () => void;

beforeEach(() => {
document.body.replaceChildren();
series = [trend()];
seeks = [];
unsubscribe?.();
unsubscribe = eventBus.on('timeline:navigate-to', (detail) => {
seeks.push({ timestamp: detail.timestamp, mode: detail.mode });
});
});

describe('governor-trends', () => {
it('moves the timeline to the instant clicked on a chart', async () => {
const element = await mount();

chartOf(element).dispatchEvent(new MouseEvent('click', { clientX: 60 }));

expect(seeks).toEqual([{ timestamp: 600, mode: 'seek' }]);
});

it('reads the sample under the pointer without moving the timeline', async () => {
const element = await mount();
const chart = chartOf(element);

chart.dispatchEvent(new MouseEvent('pointermove', { clientX: 40 }));
await element.updateComplete;

expect(element.shadowRoot?.querySelector('.trend__value')?.textContent).toContain('40');
expect(element.shadowRoot?.querySelector('.trend__cursor')).not.toBeNull();
expect(seeks).toEqual([]);
});

it('steps the cursor with the arrow keys and seeks it with Enter', async () => {
const element = await mount();
const chart = chartOf(element);

chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));

// Two steps of a seek window (2%) across a 1,000ns log.
expect(seeks).toEqual([{ timestamp: 40, mode: 'seek' }]);
});

it('keeps an arrow-key cursor when the pointer leaves the chart', async () => {
const element = await mount();
const chart = chartOf(element);

chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
chart.dispatchEvent(new MouseEvent('pointerleave'));
chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));

expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]);
});

it('holds the cursor inside the log at either end', async () => {
const element = await mount();
const chart = chartOf(element);

chart.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
chart.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));

expect(seeks).toEqual([{ timestamp: 0, mode: 'seek' }]);
});

it('seeks the last sample when no cursor has been placed', async () => {
const element = await mount();

chartOf(element).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));

expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]);
});

// A button, so the focus ring only shows for keyboard focus, never a click.
it('gives every chart keyboard reach', async () => {
const element = await mount();

expect(chartOf(element).tagName).toBe('BUTTON');
});
});
Loading