mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-25 10:54:56 +02:00
feat(dev): report frontend browser diagnostics
This commit is contained in:
69
.github/scripts/chrome.mts
vendored
69
.github/scripts/chrome.mts
vendored
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
|
||||
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
@@ -89,11 +90,65 @@ export type TabMemory = {
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type BrowserConsoleMetrics = {
|
||||
log: number;
|
||||
warn: number;
|
||||
error: number;
|
||||
info: number;
|
||||
};
|
||||
|
||||
export type BrowserDiagnostics = {
|
||||
pageErrorCount: number;
|
||||
console: BrowserConsoleMetrics;
|
||||
};
|
||||
|
||||
export function createBrowserDiagnostics(): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount: 0,
|
||||
console: {
|
||||
log: 0,
|
||||
warn: 0,
|
||||
error: 0,
|
||||
info: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordPageError(diagnostics: BrowserDiagnostics) {
|
||||
diagnostics.pageErrorCount += 1;
|
||||
}
|
||||
|
||||
function isBrowserConsoleMetricKey(value: string): value is keyof BrowserConsoleMetrics {
|
||||
return value === 'log' || value === 'warn' || value === 'error' || value === 'info';
|
||||
}
|
||||
|
||||
export function recordConsoleMessageType(diagnostics: BrowserDiagnostics, messageType: string) {
|
||||
const metricKey = messageType === 'warning' ? 'warn' : messageType;
|
||||
if (!isBrowserConsoleMetricKey(metricKey)) return;
|
||||
diagnostics.console[metricKey] += 1;
|
||||
}
|
||||
|
||||
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
|
||||
if (samples.length === 0) throw new Error('No browser diagnostic samples');
|
||||
const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select));
|
||||
|
||||
return {
|
||||
pageErrorCount: median(sample => sample.pageErrorCount),
|
||||
console: {
|
||||
log: median(sample => sample.console.log),
|
||||
warn: median(sample => sample.console.warn),
|
||||
error: median(sample => sample.console.error),
|
||||
info: median(sample => sample.console.info),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
@@ -149,6 +204,7 @@ type PlaywrightBrowserOptions = {
|
||||
export class HeadlessChromeController {
|
||||
public networkRequests: NetworkRequest[] = [];
|
||||
public webSocketConnections: WebSocketConnection[] = [];
|
||||
private readonly diagnostics = createBrowserDiagnostics();
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
@@ -168,6 +224,12 @@ export class HeadlessChromeController {
|
||||
this.cdp = cdp;
|
||||
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
|
||||
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
|
||||
this.page.on('pageerror', () => {
|
||||
recordPageError(this.diagnostics);
|
||||
});
|
||||
this.page.on('console', message => {
|
||||
recordConsoleMessageType(this.diagnostics, message.type());
|
||||
});
|
||||
}
|
||||
|
||||
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
|
||||
@@ -376,6 +438,13 @@ export class HeadlessChromeController {
|
||||
]) as T;
|
||||
}
|
||||
|
||||
public collectDiagnostics(): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount: this.diagnostics.pageErrorCount,
|
||||
console: { ...this.diagnostics.console },
|
||||
};
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
|
||||
14
.github/scripts/frontend-browser-report.mts
vendored
14
.github/scripts/frontend-browser-report.mts
vendored
@@ -8,13 +8,14 @@ import { pathToFileURL } from 'node:url';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts';
|
||||
import type { NetworkRequest } from './chrome.mts';
|
||||
import type { BrowserDiagnostics, NetworkRequest } from './chrome.mts';
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: {
|
||||
requestCount: number;
|
||||
@@ -171,8 +172,19 @@ function getMetric(report: BrowserMeasurement, key: string) {
|
||||
return report.performance.cdpMetrics[key];
|
||||
}
|
||||
|
||||
export function renderBrowserDiagnosticsRows(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
|
||||
return [
|
||||
metricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all),
|
||||
metricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all),
|
||||
metricRow('Console warnings', base, head, summary => summary.diagnostics.console.warn, sample => sample.diagnostics.console.warn, util.formatNumber, 1, !all),
|
||||
metricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all),
|
||||
metricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all),
|
||||
].filter(row => row != null);
|
||||
}
|
||||
|
||||
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
|
||||
const rows = [
|
||||
...renderBrowserDiagnosticsRows(base, head, all),
|
||||
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
|
||||
metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all),
|
||||
//metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
|
||||
|
||||
98
.github/scripts/frontend-browser-report.test.mts
vendored
Normal file
98
.github/scripts/frontend-browser-report.test.mts
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBrowserDiagnostics,
|
||||
recordConsoleMessageType,
|
||||
recordPageError,
|
||||
summarizeBrowserDiagnostics,
|
||||
type BrowserDiagnostics,
|
||||
} from './chrome.mts';
|
||||
import {
|
||||
renderBrowserDiagnosticsRows,
|
||||
type BrowserMeasurement,
|
||||
type BrowserMeasurementSample,
|
||||
type BrowserMetricsReport,
|
||||
} from './frontend-browser-report.mts';
|
||||
|
||||
function diagnostics(pageErrorCount: number, log: number, warn: number, error: number, info: number): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount,
|
||||
console: { log, warn, error, info },
|
||||
};
|
||||
}
|
||||
|
||||
function browserReport(label: 'base' | 'head', values: BrowserDiagnostics[]): BrowserMetricsReport {
|
||||
const samples = values.map((value, index) => ({
|
||||
round: index + 1,
|
||||
diagnostics: value,
|
||||
} as BrowserMeasurementSample));
|
||||
|
||||
return {
|
||||
label,
|
||||
timestamp: '2026-07-16T00:00:00.000Z',
|
||||
url: 'http://127.0.0.1:61812',
|
||||
scenario: 'test',
|
||||
sampleCount: samples.length,
|
||||
aggregation: 'median',
|
||||
summary: {
|
||||
diagnostics: summarizeBrowserDiagnostics(values),
|
||||
} as BrowserMeasurement,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
test('records page errors and only the requested console message types', () => {
|
||||
const result = createBrowserDiagnostics();
|
||||
recordPageError(result);
|
||||
recordPageError(result);
|
||||
for (const type of ['log', 'warning', 'error', 'info', 'debug', 'trace']) {
|
||||
recordConsoleMessageType(result, type);
|
||||
}
|
||||
|
||||
assert.deepEqual(result, diagnostics(2, 1, 1, 1, 1));
|
||||
});
|
||||
|
||||
test('summarizes browser diagnostics with a median for every counter', () => {
|
||||
const result = summarizeBrowserDiagnostics([
|
||||
diagnostics(0, 10, 20, 30, 40),
|
||||
diagnostics(4, 14, 24, 34, 44),
|
||||
diagnostics(2, 12, 22, 32, 42),
|
||||
diagnostics(3, 13, 23, 33, 43),
|
||||
diagnostics(1, 11, 21, 31, 41),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, diagnostics(2, 12, 22, 32, 42));
|
||||
});
|
||||
|
||||
test('renders each changed browser diagnostics metric as a separate row', () => {
|
||||
const base = browserReport('base', Array.from({ length: 5 }, () => diagnostics(0, 0, 0, 0, 0)));
|
||||
const head = browserReport('head', Array.from({ length: 5 }, () => diagnostics(1, 1, 1, 1, 1)));
|
||||
const rows = renderBrowserDiagnosticsRows(base, head).join('\n');
|
||||
|
||||
for (const label of ['Page errors', 'Console log', 'Console warnings', 'Console errors', 'Console info']) {
|
||||
assert.match(rows, new RegExp(`\\*\\*${label}\\*\\*`));
|
||||
}
|
||||
});
|
||||
|
||||
test('renders browser diagnostics deltas from paired rounds instead of summary medians', () => {
|
||||
const base = browserReport('base', [0, 9, 10, 100, 101].map(value => diagnostics(value, 0, 0, 0, 0)));
|
||||
const head = browserReport('head', [10, 100, 101, 0, 9].map(value => diagnostics(value, 0, 0, 0, 0)));
|
||||
const rows = renderBrowserDiagnosticsRows(base, head);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.match(rows[0], /\*\*Page errors\*\*/);
|
||||
assert.match(rows[0], /\\text\{\+10\}/);
|
||||
});
|
||||
|
||||
test('omits browser diagnostics rows whose paired median does not change', () => {
|
||||
const values = Array.from({ length: 5 }, () => diagnostics(2, 3, 4, 5, 6));
|
||||
const base = browserReport('base', values);
|
||||
const head = browserReport('head', values);
|
||||
|
||||
assert.deepEqual(renderBrowserDiagnosticsRows(base, head), []);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import { HeadlessChromeController, summarizeNetwork } from './chrome.mts';
|
||||
import { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork } from './chrome.mts';
|
||||
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
|
||||
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
|
||||
|
||||
@@ -173,6 +173,7 @@ function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSam
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: representative.scenario,
|
||||
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
|
||||
durationMs: finiteMedian(samples.map(sample => sample.durationMs)),
|
||||
network: summarizeNetworkSamples(samples),
|
||||
performance: summarizePerformanceSamples(samples),
|
||||
@@ -210,6 +211,7 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
|
||||
diagnostics: chrome.collectDiagnostics(),
|
||||
durationMs,
|
||||
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
|
||||
networkRequests: chrome.networkRequests,
|
||||
|
||||
Reference in New Issue
Block a user