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 @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The Timeline governor strip plots heap as it's allocated, so you can see where it spikes.
- 🧭 **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])
- **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, how few statements hold the time, and every call path that ends in a query, DML or search with total and self 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`. 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.
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 @@ -41,7 +41,7 @@ With nothing selected the inspector reads the whole log. Every tab opens with an

- **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.
- **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. **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. **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.
- **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.
**Self time spread**: how few signatures hold 80% of the log’s self time, then a histogram of per-call self time for each of the busiest repeated signatures, with the median and the 95th call marked. The grid gives an average, which reads the same whether every call is slow or one call is; the shape tells them apart. Move the pointer across a lane to read how many calls a bucket holds. A call the log made once has no shape, so the costliest of them are named under **Ran once**. Only calls the log timed count. Click any row to select its worst call.

Expand Down
2 changes: 1 addition & 1 deletion log-viewer/src/components/CategoryTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class CategoryTimeBar extends LitElement {
label="Time by category"
.segments=${slices.map((slice) => ({
label: slice.category,
timeNs: slice.selfTime,
value: slice.selfTime,
color: this._palette.colorFor(slice.category),
}))}
></stacked-time-bar>`;
Expand Down
2 changes: 1 addition & 1 deletion log-viewer/src/components/NamespaceTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class NamespaceTimeBar extends LitElement {
MAX_SEGMENTS,
(slice) => ({
label: slice.namespace,
timeNs: slice.selfTime,
value: slice.selfTime,
color: color(slice.namespace),
}),
(slice) => slice.selfTime,
Expand Down
66 changes: 47 additions & 19 deletions log-viewer/src/components/StackedTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { styleMap } from 'lit/directives/style-map.js';
import { formatDuration, formatInteger } from '../core/utility/Util.js';
import { globalStyles } from '../styles/global.styles.js';

/** One coloured length of a {@link StackedTimeBar}, measured in nanoseconds. */
/** One coloured length of a {@link StackedTimeBar}, in the bar's own unit. */
export interface StackedSegment {
label: string;
timeNs: number;
value: number;
color: string;
/** What the segment is made of. Shown in the tip. */
detail?: string;
Expand All @@ -22,37 +22,39 @@ export interface StackedSegment {
* The first `max` rows as segments, with everything past them gathered into one
* muted tail, so a bar with more rows than it can colour still totals the whole.
*
* The tail reads its time through `timeOf`, so a row past `max` never pays for a
* The tail reads its size through `sizeOf`, so a row past `max` never pays for a
* segment that is thrown away.
*/
export function segmentsWithTail<T>(
rows: readonly T[],
max: number,
toSegment: (row: T) => StackedSegment,
timeOf: (row: T) => number,
toSegment: (row: T, index: number) => StackedSegment,
sizeOf: (row: T) => number,
): StackedSegment[] {
const segments = rows.slice(0, max).map(toSegment);
if (rows.length > max) {
let tailNs = 0;
let tail = 0;
for (let index = max; index < rows.length; index++) {
tailNs += timeOf(rows[index]!); // in range: below rows.length
tail += sizeOf(rows[index]!); // in range: below rows.length
}
segments.push({
label: `${formatInteger(rows.length - max)} others`,
timeNs: tailNs,
value: tail,
color: 'var(--lana-fg-muted)',
});
}
return segments;
}

/**
* Durations as one stacked bar: a segment per kind, in the flame chart's own
* colours, with a hover readout and an optional legend.
* One quantity as a stacked bar: a segment per part, in the flame chart's own
* colours, with a hover readout and an optional legend. `format` says what the
* segments are — durations, or counts such as rows against a governor limit.
*
* `total` is the bar's denominator. Set it above the segments' sum and the
* shortfall stays unfilled, so the bar shows a share of something larger — the
* database against the whole log — rather than only a split of itself.
* database against the whole log — rather than only a split of itself. Below
* their sum the segments answer instead, and a mark shows where the total fell.
*
* Set `--stacked-bar-height` to size it; a row inside a list wants it thinner
* than a section's own chart.
Expand All @@ -62,10 +64,14 @@ export class StackedTimeBar extends LitElement {
@property({ attribute: false })
segments: readonly StackedSegment[] = [];

/** Denominator (ns). Zero or below the segments' sum: the sum answers. */
/** Denominator, in the segments' unit. Zero or below their sum: the sum answers. */
@property({ type: Number })
total = 0;

/** How a figure reads. A count bar passes `formatInteger`. */
@property({ attribute: false })
format: (value: number) => string = formatDuration;

/** Show the figures beneath the bar, one item per segment. */
@property({ type: Boolean })
legend = false;
Expand Down Expand Up @@ -102,6 +108,16 @@ export class StackedTimeBar extends LitElement {
background: color-mix(in srgb, var(--lana-meter-fill) 22%, transparent);
}

/* Where the total fell once the segments passed it. A line rather than an
edge: the segments own the whole bar by then. */
.limit {
position: absolute;
pointer-events: none;
inset-block: 0;
width: var(--lana-stroke);
background: var(--lana-fg);
}

/* The readout. The legend carries the same figures, but a narrow or
scrolled panel can push it out of view, so the bar answers too. It follows
the pointer along the bar, and sits below it because above it the section
Expand Down Expand Up @@ -175,7 +191,7 @@ export class StackedTimeBar extends LitElement {
];

render() {
const sum = this.segments.reduce((running, segment) => running + segment.timeNs, 0);
const sum = this.segments.reduce((running, segment) => running + segment.value, 0);
const denominator = Math.max(this.total, sum);
if (denominator <= 0) {
return html``;
Expand All @@ -185,10 +201,13 @@ export class StackedTimeBar extends LitElement {
let x = 0;
const laid = this.segments.map((segment) => {
const start = x;
const width = (segment.timeNs / denominator) * 100;
const width = (segment.value / denominator) * 100;
x += width;
return { ...segment, start, width };
});
// Only once the segments pass the total: inside it the unfilled remainder is
// already the mark.
const limitPercent = this.total > 0 && sum > this.total ? (this.total / sum) * 100 : null;
const hover = this._hover;
const hovered = hover ? laid.find((s) => s.label === hover.label) : undefined;
// A bar hover always gets the readout; a legend hover only when the segment
Expand Down Expand Up @@ -223,6 +242,15 @@ export class StackedTimeBar extends LitElement {
></rect>`,
)}
</svg>
${
limitPercent === null
? ''
: html`<span
class="limit"
style=${styleMap({ left: `${limitPercent.toFixed(1)}%` })}
title=${`Limit ${this.format(this.total)}`}
></span>`
}
${
tipSlice
? html`<div
Expand All @@ -234,7 +262,7 @@ export class StackedTimeBar extends LitElement {
)}
>
${tipSlice.label} ·
${readout(tipSlice.timeNs, denominator)}${
${readout(tipSlice.value, denominator, this.format)}${
tipSlice.detail ? ` · ${tipSlice.detail}` : ''
}
</div>`
Expand Down Expand Up @@ -269,17 +297,17 @@ export class StackedTimeBar extends LitElement {
>
<span class="legend__swatch" style=${styleMap({ background: segment.color })}></span>
<span>${segment.label}</span>
<span class="legend__value">${readout(segment.timeNs, denominator)}</span>
<span class="legend__value">${readout(segment.value, denominator, this.format)}</span>
</span>
`,
)}
</div>`;
}
}

/** `duration · percent` — the tip and the legend show the same figures. */
function readout(timeNs: number, denominator: number): string {
return `${formatDuration(timeNs)} · ${((timeNs / denominator) * 100).toFixed(1)}%`;
/** `figure · percent` — the tip and the legend show the same figures. */
function readout(value: number, denominator: number, format: (value: number) => string): string {
return `${format(value)} · ${((value / denominator) * 100).toFixed(1)}%`;
}

declare global {
Expand Down
10 changes: 5 additions & 5 deletions log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ describe('namespace-time-bar', () => {
it('splits the whole log by namespace, largest first', async () => {
logOf([ev('default', 100, [ev('pkg', 500)])], ['pkg']);

expect(segments(await mount()).map(({ label, timeNs }) => ({ label, timeNs }))).toEqual([
{ label: 'pkg', timeNs: 500 },
{ label: 'default', timeNs: 100 },
expect(segments(await mount()).map(({ label, value }) => ({ label, value }))).toEqual([
{ label: 'pkg', value: 500 },
{ label: 'default', value: 100 },
]);
});

Expand All @@ -78,7 +78,7 @@ describe('namespace-time-bar', () => {

const element = await mount({ instances: [outer.eventIndex, inner.eventIndex] });

expect(segments(element)[0]).toMatchObject({ label: 'pkg', timeNs: 50 });
expect(segments(element)[0]).toMatchObject({ label: 'pkg', value: 50 });
});

it('gathers the namespaces past the cap into one tail segment', async () => {
Expand All @@ -96,7 +96,7 @@ describe('namespace-time-bar', () => {

expect(shown).toHaveLength(MAX_SEGMENTS + 1);
// nsA at 20 and nsB at 10.
expect(shown.at(-1)).toMatchObject({ label: '2 others', timeNs: 30 });
expect(shown.at(-1)).toMatchObject({ label: '2 others', value: 30 });
});

it('notes a scope with no recorded time', async () => {
Expand Down
15 changes: 13 additions & 2 deletions log-viewer/src/components/__tests__/StackedTimeBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import '../StackedTimeBar.js';
import type { StackedSegment } from '../StackedTimeBar.js';

const SEGMENTS: StackedSegment[] = [
{ label: 'SOQL', timeNs: 200_000_000, color: 'red' },
{ label: 'DML', timeNs: 100_000_000, color: 'blue' },
{ label: 'SOQL', value: 200_000_000, color: 'red' },
{ label: 'DML', value: 100_000_000, color: 'blue' },
];

async function mount(segments: StackedSegment[], total = 0, legend = false) {
Expand Down Expand Up @@ -50,6 +50,17 @@ describe('stacked-time-bar', () => {
expect(widths(await mount(SEGMENTS, 1_000))).toEqual([66.667, 33.333]);
});

it('marks where the total fell once the segments passed it', async () => {
// 300ms against a 200ms total: the mark sits at two thirds of the bar.
const mark = (await mount(SEGMENTS, 200_000_000)).shadowRoot?.querySelector('.limit');

expect((mark as HTMLElement | null)?.style.left).toBe('66.7%');
});

it('needs no mark while the total still holds the segments', async () => {
expect((await mount(SEGMENTS, 1_000_000_000)).shadowRoot?.querySelector('.limit')).toBeNull();
});

it('reads out the hovered segment against the total', async () => {
const element = await mount(SEGMENTS, 1_000_000_000);
element.shadowRoot?.querySelector('rect')?.dispatchEvent(new Event('pointerenter'));
Expand Down
3 changes: 2 additions & 1 deletion log-viewer/src/components/__tests__/detailSections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jest.mock('../HotSpots.js', () => ({}));
jest.mock('../LogOverview.js', () => ({}));
jest.mock('../NamespaceTimeBar.js', () => ({}));
jest.mock('../../features/database/components/DatabaseOverview.js', () => ({}));
jest.mock('../../features/database/components/DatabaseRowBudget.js', () => ({}));
jest.mock('../../features/database/components/DatabaseTimeTree.js', () => ({}));

const databaseCalls: { eventIndex: number; type: string; activeEventIndex?: number | null }[] = [];
Expand Down Expand Up @@ -214,8 +215,8 @@ describe('buildDetailSections', () => {
expect(sections.map((s) => s.id)).toEqual([
'overview',
'database-namespaces',
'database-time',
'database-concentration',
'database-time',
]);
expect(sections[0]?.title).toBe('Overview');
// The call-path grid soaks up the leftover space; the rest keep their own.
Expand Down
23 changes: 14 additions & 9 deletions log-viewer/src/components/detailSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,27 @@ export async function buildDetailSections(
);
}
if (source === 'database') {
// The Database tab's whole-log analogue, widest question first: whose code
// holds the database time, which call paths reach it, and how few
// statements it comes down to.
// The Database tab's whole-log analogue: whose code holds the database
// time, how few statements it comes down to, and which call paths reach it.
//
// The Row budget section is written and tested but held back while the tab
// is judged for length. To re-add it, restore the import of
// DatabaseRowBudget.js and this section, first in the list:
// { id: 'database-rows', title: 'Row budget', fit: 'content',
// content: html`<database-rows></database-rows>` },
sections.push(
{
id: 'database-namespaces',
title: 'Namespace duration',
fit: 'content',
content: html`<database-namespaces></database-namespaces>`,
},
{
id: 'database-concentration',
title: 'Database duration',
fit: 'content',
content: html`<database-concentration></database-concentration>`,
},
{
// A grid sized to the pane it is in, so this section takes the space
// the sized-to-content ones leave.
Expand All @@ -112,12 +123,6 @@ export async function buildDetailSections(
weight: 4,
content: html`<database-time></database-time>`,
},
{
id: 'database-concentration',
title: 'Database duration',
fit: 'content',
content: html`<database-concentration></database-concentration>`,
},
);
}
if (source === 'timeline') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,6 @@ export class LogDiagnosticsView extends LitElement {
}

.detail {
margin: 0;
color: var(--lana-fg-muted);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function shareText(percent: number): string {
* search as SOQL, so SOSL has no hue of its own: it shows as a tint of the query
* hue, which keeps these graphics truthful against the chart.
*/
function kindColors(palette: CategoryPaletteController): Record<StatementKind, string> {
export function kindColors(palette: CategoryPaletteController): Record<StatementKind, string> {
const soql = palette.colorFor('SOQL');
return {
SOQL: soql,
Expand Down Expand Up @@ -134,7 +134,6 @@ export class DatabaseConcentration extends LitElement {
}

.headline {
margin: 0;
padding-bottom: var(--lana-space-xs);
font-size: var(--lana-text-sm);
}
Expand Down Expand Up @@ -301,7 +300,7 @@ export class DatabaseNamespaces extends LitElement {
MAX_ROWS,
(row) => ({
label: row.key,
timeNs: row.timeNs,
value: row.timeNs,
color: color(row.key),
detail: kindSplit(row),
}),
Expand Down
Loading