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
5 changes: 5 additions & 0 deletions .changeset/session-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/start-devtools': minor
---

The dock header has a button that copies one report of the whole session for an agent: the newest errors, the issues, the recent timeline actions, the flagged async records and the failed server calls.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ does not include the toolbar.

The same import is safe in development and production entries.

## Copy the whole session

The copy button in the dock header puts one Markdown report of everything the devtools
noticed on the clipboard, for when it is not clear which panel holds the problem.

- The three newest errors in full, with their mapped stacks and source.
- Every issue, with its count and owner.
- The five newest timeline actions, with their writes, re-runs and calls.
- The async records that were flagged, such as a wait with no pending state.
- The server calls that failed.

A section only appears when its panel has something in it. Values with sensitive names are
redacted, as in the reports of each panel.

## Demo

Two demo apps live in `examples`.
Expand Down
25 changes: 25 additions & 0 deletions src/dev-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
} from './icons.js';
import { AsyncHistory, startAsyncCapture, type AsyncRecord } from './async/tracker.js';
import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js';
import { CopyReportButton } from './report/CopyReportButton.js';
import { pageRoute } from './report/markdown.js';
import { excludeOwner, includeOwner } from './ownership/registry.js';
import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js';
import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js';
Expand Down Expand Up @@ -366,6 +368,28 @@ export function DevToolbar(props: DevToolbarProps) {

const callCount = createMemo(() => Object.keys(instances()).length);

async function copySession(): Promise<string> {
// Loaded on use: mapping stacks needs the source-map reader the error panel loads lazily.
const [{ resolveErrorFrames }, { SESSION_ERRORS, sessionReport }] = await Promise.all([
import('./report/error-report.js'),
import('./report/session-report.js'),
]);
const recent = errors().slice(0, SESSION_ERRORS);
const resolved = await Promise.all(
recent.map(async (error) => ({ error, frames: await resolveErrorFrames(error) })),
);
return sessionReport({
errors: resolved,
errorCount: errors().length,
issues: issues(),
timeline: timelineEntries(),
asyncRecords: asyncRecords(),
calls: Object.values(instances()).filter((call) => call !== undefined),
route: pageRoute(),
version,
});
}

const badge = (value: Tab): number => {
if (value === 'err') return errors().length;
if (value === 'issues') return issueCount();
Expand Down Expand Up @@ -435,6 +459,7 @@ export function DevToolbar(props: DevToolbarProps) {
{route()}
</span>
<div data-solid-dev-toolbar-spacer />
<CopyReportButton label="the whole session" report={copySession} />
<IconButton onClick={close}>
<MinimizeIcon title="Close Start Devtools" />
</IconButton>
Expand Down
133 changes: 133 additions & 0 deletions src/dev-toolbar/report/session-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { AsyncHistory } from '../async/tracker.js';
import type { ServerFunctionInstance } from '../functions/index.js';
import { addIssues } from '../issues/tracker.js';
import { TimelineBuilder } from '../timeline/tracker.js';
import { demote, sessionReport, type SessionReportInput } from './session-report.js';

function input(over: Partial<SessionReportInput> = {}): SessionReportInput {
return {
errors: [],
errorCount: 0,
issues: [],
timeline: [],
asyncRecords: [],
calls: [],
route: '/todos',
version: '1.0.0',
...over,
};
}

describe('demote', () => {
it('moves headings down a level and leaves code blocks alone', () => {
expect(demote('# Title\n\n```\n# not a heading\n```\n## Part')).toBe(
'## Title\n\n```\n# not a heading\n```\n### Part',
);
});
});

describe('sessionReport', () => {
it('says when there is nothing to report', () => {
const report = sessionReport(input());
expect(report).toContain('# Solid devtools session');
expect(report).toContain('- Page: `/todos`');
expect(report).toContain('Nothing to report');
});

it('has a section for each panel with something in it', () => {
const issues = addIssues(
[],
[
{
event: {
sequence: 1,
code: 'STRICT_READ_UNTRACKED',
kind: 'strict-read',
severity: 'warn',
message: '[STRICT_READ_UNTRACKED] Read outside tracking.',
ownerPath: ['<App>', '<Draft>'],
},
subject: undefined,
at: 1,
},
],
);

const timeline = new TimelineBuilder();
const click = { kind: 'interaction' as const, name: 'click', target: 'button#inc', at: 1 };
timeline.rerun(
{
run: 1,
at: 2,
nodeRuns: 1,
nodeKind: 'memo',
nodeName: 'doubled',
nodeId: 1,
causes: [{ seq: 1, kind: 'write', name: 'count', prev: '0', value: '1', origin: click }],
depCount: 1,
depsAdded: [],
depsRemoved: [],
selfMs: 0.2,
totalMs: 0.2,
changed: true,
phase: 'plain',
held: false,
interaction: click,
},
undefined,
);

const history = new AsyncHistory();
history.fallback({ at: 1, shownMs: 80, ownerPath: ['<App>', '<Loading>'] }, undefined);
history.fallback({ at: 1, shownMs: 400 }, undefined);

const calls: ServerFunctionInstance[] = [
{
request: {
type: 'request',
id: 'save',
instance: 'save#1',
source: new Request('http://localhost/_server', { method: 'POST' }),
meta: { name: 'save' },
time: 1,
},
response: {
type: 'response',
id: 'save',
instance: 'save#1',
source: new Response(null, { status: 500 }),
meta: { name: 'save' },
time: 2,
},
},
];

const report = sessionReport(
input({
errors: [{ error: new TypeError('boom'), frames: [] }],
errorCount: 4,
issues,
timeline: timeline.snapshot(),
asyncRecords: history.records,
calls,
}),
);

expect(report).toContain('## Errors (4)');
// The error report sits inside its section, two levels down.
expect(report).toContain('### Error in a Solid app');
expect(report).toContain('TypeError: boom');
expect(report).toContain('… 3 older errors not shown.');
expect(report).toContain(
'## Issues (1)\n\n- warn `STRICT_READ_UNTRACKED` in `<App> > <Draft>`: Read outside tracking.',
);
expect(report).toContain(
'## Recent actions, newest first\n\n- `click on button#inc`, 1 write, 1 re-run',
);
expect(report).toContain(
'## Async concerns (1)\n\n- `fallback at <App> › <Loading>`, 80.0ms: flash',
);
expect(report).toContain('## Failed server calls (1)\n\n- POST `save` → 500');
});
});
157 changes: 157 additions & 0 deletions src/dev-toolbar/report/session-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { concernOf, type AsyncRecord } from '../async/tracker.js';
import type { ServerFunctionInstance } from '../functions/index.js';
import { issueText, type IssueEntry } from '../issues/tracker.js';
import { isUnnamedUpdate, type TimelineEntry } from '../timeline/tracker.js';
import { asyncDuration, asyncOrigin, asyncTitle } from './async-report.js';
import { errorReport, type ReportFrame } from './error-report.js';
import { code } from './markdown.js';
import { redactText, redactUrl } from './redact.js';
import { ms, plural } from './timeline-report.js';

/** Errors the session report gives in full, newest first. The rest are counted. */
export const SESSION_ERRORS = 3;
/** Issues the session report lists. */
export const SESSION_ISSUES = 20;
/** Timeline actions the session report lists, newest first. */
export const SESSION_ACTIONS = 5;
/** Flagged async records the session report lists. */
export const SESSION_ASYNC = 10;

export interface SessionError {
error: unknown;
frames: ReportFrame[];
}

export interface SessionReportInput {
/** Newest first, as the error panel keeps them. */
errors: SessionError[];
/** How many errors there are in all, which can be more than `errors` holds. */
errorCount: number;
issues: IssueEntry[];
timeline: TimelineEntry[];
asyncRecords: AsyncRecord[];
calls: ServerFunctionInstance[];
/** The path and query of the page. */
route: string;
/** The devtools version, so a reader knows what made the report. */
version: string;
}

/**
* Puts a report one heading level lower, so it reads as a section of another
* report. Lines inside code blocks are left alone.
*/
export function demote(markdown: string): string {
let fence: string | undefined;
return markdown
.split('\n')
.map((line) => {
const marks = line.match(/^(`{3,})/)?.[1];
if (marks && (fence === undefined || marks.length >= fence.length)) {
fence = fence === undefined ? marks : undefined;
return line;
}
return fence === undefined && line.startsWith('#') ? `#${line}` : line;
})
.join('\n');
}

function failedCall(instance: ServerFunctionInstance): string | undefined {
const { request, response } = instance;
const name = code(request.meta?.name ?? request.id);
if (response && !response.source.ok) {
return `- ${request.source.method} ${name} → ${response.source.status}`;
}
const error = request.meta?.error;
if (!response && error !== undefined) {
const message = error instanceof Error ? error.message : String(error);
return `- ${request.source.method} ${name} → no response: ${redactText(message)}`;
}
return undefined;
}

function actionLine(entry: TimelineEntry): string {
const parts = [
plural(entry.writes.length, 'write'),
plural(entry.interaction?.runs ?? entry.runs.length + entry.droppedRuns, 're-run'),
];
if (entry.calls.length) parts.push(plural(entry.calls.length, 'call'));
const time = entry.interaction?.settledMs ?? entry.flushMs;
const outcome = entry.interaction?.outcome ? `, ${entry.interaction.outcome}` : '';
const writes = entry.writes.length
? `: ${entry.writes.map((write) => code(write.name)).join(', ')}`
: '';
return `- ${code(entry.label)}, ${parts.join(', ')} in ${ms(time)}${outcome}${writes}`;
}

/**
* One Markdown report of everything the devtools noticed, for an agent to
* read when it is not clear which panel holds the problem. Each panel adds a
* section only when it has something in it.
*/
export function sessionReport(input: SessionReportInput): string {
const out: string[] = ['# Solid devtools session', ''];
out.push(`- Page: ${code(redactUrl(input.route))}`);
out.push(`- Devtools: ${input.version}`);

const failed = input.calls.map(failedCall).filter((line) => line !== undefined);
// The same updates the timeline hides by default, so the newest actions are real ones.
const actions = input.timeline.filter((entry) => !isUnnamedUpdate(entry));
const flagged = input.asyncRecords.filter((record) => concernOf(record));
const empty =
!input.errorCount &&
!input.issues.length &&
!actions.length &&
!flagged.length &&
!failed.length;
if (empty) {
out.push('', 'Nothing to report: no errors, issues, actions, async concerns or failed calls.');
return out.join('\n');
}

if (input.errorCount) {
out.push('', `## Errors (${input.errorCount})`);
for (const { error, frames } of input.errors.slice(0, SESSION_ERRORS)) {
out.push('', demote(demote(errorReport({ error, frames, route: input.route }))));
}
const rest = input.errorCount - Math.min(input.errors.length, SESSION_ERRORS);
if (rest > 0) out.push('', `… ${plural(rest, 'older error')} not shown.`);
}

if (input.issues.length) {
out.push('', `## Issues (${input.issues.length})`, '');
for (const entry of input.issues.slice(0, SESSION_ISSUES)) {
const { event, count } = entry;
const times = count > 1 ? ` ×${count}` : '';
const owner = event.ownerPath?.length ? ` in ${code(event.ownerPath.join(' > '))}` : '';
out.push(
`- ${event.severity} ${code(event.code)}${times}${owner}: ${redactText(issueText(event))}`,
);
}
const rest = input.issues.length - SESSION_ISSUES;
if (rest > 0) out.push(`- … ${plural(rest, 'more issue')}`);
}

if (actions.length) {
out.push('', '## Recent actions, newest first', '');
for (const entry of actions.slice(0, SESSION_ACTIONS)) out.push(actionLine(entry));
}

if (flagged.length) {
out.push('', `## Async concerns (${flagged.length})`, '');
for (const record of flagged.slice(0, SESSION_ASYNC)) {
const origin = asyncOrigin(record);
out.push(
`- ${code(asyncTitle(record))}, ${ms(asyncDuration(record))}: ${concernOf(record)}${origin ? `, started by ${code(origin)}` : ''}`,
);
}
}

if (failed.length) {
out.push('', `## Failed server calls (${failed.length})`, '');
out.push(...failed);
}

out.push('', 'Each panel of the devtools has a copy button for the full report of one item.');
return out.join('\n');
}
Loading
Loading