diff --git a/.claude/THIRD_PARTY_LICENSES.md b/.claude/THIRD_PARTY_LICENSES.md index 0fc5df629c..c81079e979 100644 --- a/.claude/THIRD_PARTY_LICENSES.md +++ b/.claude/THIRD_PARTY_LICENSES.md @@ -19,7 +19,7 @@ |---|---|---|---| | `skills/context-budget/SKILL.md` | `skills/context-budget/SKILL.md` | ECC | description を日本語化、Misskey 固有メモを追記 | | `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、Claude が `pnpm`/`git`/`grep` で手動採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み | -| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsgo + ESLint + Vitest) に。Prettier/Biome フェーズを削除 | +| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsc + ESLint + Vitest) に。Prettier/Biome フェーズを削除 | ### MIT License (full text) diff --git a/.claude/commands/quality-gate.md b/.claude/commands/quality-gate.md index 720ab56fa4..c4477a7f28 100644 --- a/.claude/commands/quality-gate.md +++ b/.claude/commands/quality-gate.md @@ -12,9 +12,9 @@ upstream path: commands/quality-gate.md upstream license: MIT — https://github.com/affaan-m/everything-claude-code/blob/main/LICENSE project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サードパーティ一覧 + MIT 全文) -Imported into Misskey .claude/ on 2026-05-10. Pipeline 概念 (lint → typecheck → test) は upstream ECC 版から借用 (MIT)。実コマンド層は Misskey の pnpm + tsgo + ESLint + Vitest に固定し、formatter (Prettier/Biome) フェーズは削除した。 +Imported into Misskey .claude/ on 2026-05-10. Pipeline 概念 (lint → typecheck → test) は upstream ECC 版から借用 (MIT)。実コマンド層は Misskey の pnpm + tsc + ESLint + Vitest に固定し、formatter (Prettier/Biome) フェーズは削除した。 -note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsgo + ESLint + Vitest の組み合わせに固定。重い test:e2e / test:fed は含まない (CI 側で実行される)。 +note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。重い test:e2e / test:fed は含まない (CI 側で実行される)。 --> # /quality-gate — Misskey 軽量品質ゲート @@ -50,7 +50,7 @@ pnpm --filter frontend test lint がまとめて失敗していて typecheck の結果だけ単独で見たい場合は、以下を個別に回す。**通常は不要** (lint の出力を読めば足りる): ```bash -pnpm --filter backend typecheck # tsgo 単体 +pnpm --filter backend typecheck # tsc 単体 pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るため) ``` @@ -63,7 +63,7 @@ pnpm --filter backend lint pnpm --filter backend test ``` -`tsgo` の出力を単独で見たい時のみ optional で `pnpm --filter backend typecheck` を別途回す。 +`tsc` の出力を単独で見たい時のみ optional で `pnpm --filter backend typecheck` を別途回す。 ### Frontend scope diff --git a/.claude/skills/working-on-backend/references/knowledge/nestjs-di.md b/.claude/skills/working-on-backend/references/knowledge/nestjs-di.md index 6c75e1fca1..548c685892 100644 --- a/.claude/skills/working-on-backend/references/knowledge/nestjs-di.md +++ b/.claude/skills/working-on-backend/references/knowledge/nestjs-di.md @@ -6,7 +6,7 @@ Misskey の backend は NestJS 11 + Fastify 5 + TypeORM 1 (PostgreSQL) + Redis - **DI コンテナ**: NestJS の `@Injectable()` サービス + Repository (TypeORM) パターン - **DI トークン**: [`@/di-symbols.js`](../../../../../packages/backend/src/di-symbols.ts) の `DI` から `@Inject(DI.xxx)` で注入 -- **ビルド**: `rolldown -c` で `built/` にバンドル。型チェックは `tsgo` +- **ビルド**: `rolldown -c` で `built/` にバンドル。型チェックは `tsc` ## エンドポイント内での DI diff --git a/.claude/skills/working-on-backend/references/tasks/adding-api-endpoint.md b/.claude/skills/working-on-backend/references/tasks/adding-api-endpoint.md index c1d6445338..927fd61fec 100644 --- a/.claude/skills/working-on-backend/references/tasks/adding-api-endpoint.md +++ b/.claude/skills/working-on-backend/references/tasks/adding-api-endpoint.md @@ -236,7 +236,7 @@ pnpm --filter backend test:e2e ```bash # 個別ファイルを高速にチェック pnpm exec eslint --fix packages/backend/src/server/api/endpoints//.ts -pnpm --filter backend typecheck # tsgo --noEmit (backend のみ) +pnpm --filter backend typecheck # tsc --noEmit (backend のみ) # 一括 (PR 提出前) pnpm --filter backend lint diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b10b001171..379e3f2607 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,3 +9,20 @@ reviews: drafts: false base_branches: - "develop" + ignore_title_keywords: + - "New Crowdin updates" + ignore_usernames: + - "dependabot[bot]" + - "renovate[bot]" + - "github-actions[bot]" + path_instructions: + - path: "**/*" + instructions: | + 【レビュー対象外】 + - フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。 + + 【注力してほしい観点】 + - ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。 + path_filters: + - "!CHANGELOG.md" + - "!**/__snapshots__/**" diff --git a/.github/scripts/backend-diagnostics.render-md.mts b/.github/scripts/backend-diagnostics.render-md.mts deleted file mode 100644 index 5a748584e1..0000000000 --- a/.github/scripts/backend-diagnostics.render-md.mts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import * as util from './utility.mts'; -import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import type { MemoryReport } from './backend-diagnostics.inspect.mts'; - -const [baseFile, headFile, outputFile] = process.argv.slice(2); - -const memoryReportPhases = [ - { - key: 'afterGc', - title: 'After GC', - }, -] as const; - -const memoryMetrics = [ - 'HeapUsed', - 'Pss', - 'USS', - 'External', -] as const; - -function formatMemoryMb(valueKiB: number | null | undefined) { - if (valueKiB == null) return '-'; - return `${util.formatNumber(valueKiB / 1000)} MB`; -} - -function formatMemoryMetricName(metric: typeof memoryMetrics[number]) { - return metric === 'Pss' ? 'PSS' : metric; -} - -function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const memoryUsage = sample.phases[phase].memoryUsage; - if (metric !== 'USS') return memoryUsage[metric]; - return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; -} - -function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); -} - -function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; - return util.median(getSampleValues(report, phase, metric)); -} - -function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const values = getSampleValues(report, phase, metric); - if (values.length < 2) return null; - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); -} - -function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - function formatDeltaMemory(deltaKiB: number) { - return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold - } - - for (const metric of memoryMetrics) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); - const percent = summary.median * 100 / baseValue; - const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - - lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); - } - - return lines.join('\n'); -} - -function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { - const baseHeapSnapshotReport = { - summary: base.summary.afterGc.heapSnapshot!, - samples: base.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const headHeapSnapshotReport = { - summary: head.summary.afterGc.heapSnapshot!, - samples: head.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); - if (table == null) return null; - - const lines = [ - '### V8 Heap Snapshot Statistics', - '', - table, - '', - ]; - - for (const graph of [ - //heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), - //heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), - ]) { - if (graph == null) continue; - lines.push(graph); - lines.push(''); - } - - return lines.join('\n'); -} - -const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport; -const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport; - -const lines = [ - '## ⚙️ Backend Diagnostics Report', - '', -]; - -//const summary = measurementSummary(base, head); -//if (summary != null) { -// lines.push(summary); -// lines.push(''); -//} - -for (const phase of memoryReportPhases) { - lines.push(`### Memory: ${phase.title}`); - lines.push(renderMainTableForPhase(base, head, phase.key)); - lines.push(''); -} - -const heapSnapshotSection = renderHeapSnapshotSection(base, head); -if (heapSnapshotSection != null) { - lines.push(heapSnapshotSection); - lines.push(''); -} - -const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim(); -const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); -lines.push(`Download representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); -lines.push(''); - -function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - return ((headValue - baseValue) * 100) / baseValue; -} - -function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const delta = headValue - baseValue; - if (delta <= 0) return false; - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - if (baseSpread == null || headSpread == null) return true; - - const combinedSpread = Math.hypot(baseSpread, headSpread); - return delta > combinedSpread * 3; -} - -const warningMetric = 'Pss'; -const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); -if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { - lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); - lines.push(''); -} - -await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts deleted file mode 100644 index b40c82a263..0000000000 --- a/.github/scripts/chrome.mts +++ /dev/null @@ -1,570 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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; - url: string; - method: string; - resourceType: string; - startedAt: number; - documentUrl?: string; - requestHeaders?: Record; - requestBody?: string; - hasRequestBody: boolean; - status?: number; - statusText?: string; - mimeType?: string; - responseHeaders?: Record; - protocol?: string; - remoteIPAddress?: string; - remotePort?: number; - encodedDataLength: number; - decodedBodyLength: number; - fromDiskCache: boolean; - fromServiceWorker: boolean; - finished: boolean; - failed: boolean; - errorText?: string; -}; - -export type WebSocketConnection = { - requestId: string; - url: string; - createdAt: number; - handshakeRequestHeaders?: Record; - handshakeResponseStatus?: number; - handshakeResponseStatusText?: string; - handshakeResponseHeaders?: Record; - closedAt?: number; - sentFrameCount: number; - receivedFrameCount: number; - sentBytes: number; - receivedBytes: number; - errorCount: number; -}; - -export type NetworkSummary = { - requestCount: number; - webSocketConnectionCount: number; - webSocketSentBytes: number; - webSocketReceivedBytes: number; - finishedRequestCount: number; - failedRequestCount: number; - cachedRequestCount: number; - serviceWorkerRequestCount: number; - totalEncodedBytes: number; - totalDecodedBodyBytes: number; - sameOriginEncodedBytes: number; - thirdPartyEncodedBytes: number; - byResourceType: Record; - largestRequests: { - url: string; - method: string; - resourceType: string; - status?: number; - encodedBytes: number; - decodedBodyBytes: number; - }[]; - failedRequests: { - url: string; - method: string; - resourceType: string; - errorText?: string; - status?: number; - }[]; -}; - -export type TabMemory = { - totalBytes: number; -}; - -export type BrowserDiagnostics = { - pageErrorCount: number; - console: Record<'log' | 'warning' | 'error' | 'info', number>; -}; - -export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { - const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select)); - - return { - pageErrorCount: median(sample => sample.pageErrorCount), - console: { - log: median(sample => sample.console.log), - warning: median(sample => sample.console.warning), - 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: { - cdpMetrics: Record; - runtimeHeap?: { - usedSize: number; - totalSize: number; - }; - tabMemory: TabMemory; - webVitals: { - firstPaintMs?: number; - firstContentfulPaintMs?: number; - domContentLoadedEventEndMs?: number; - loadEventEndMs?: number; - longTaskCount: number; - longTaskDurationMs: number; - maxLongTaskDurationMs: number; - resourceEntryCount: number; - domElements: number; - }; - }; - heapSnapshot: HeapSnapshotData; -}; - -type PlaywrightModule = typeof import('playwright'); - -const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url)); - -function loadPlaywright(): PlaywrightModule { - return requireFromFrontend('playwright') as PlaywrightModule; -} - -function normalizeHeaders(headers: Record | undefined) { - if (headers == null) return undefined; - const normalized = {} as Record; - for (const [key, value] of Object.entries(headers)) { - normalized[key] = String(value); - } - return normalized; -} - -function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) { - if (frame?.payloadData == null) return 0; - if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); - return Buffer.byteLength(frame.payloadData, 'base64'); -} - -type PlaywrightBrowserOptions = { - scenarioTimeoutMs: number; - baseUrl: string; -}; - -export class HeadlessChromeController { - public networkRequests: NetworkRequest[] = []; - public webSocketConnections: WebSocketConnection[] = []; - private readonly diagnostics = { - pageErrorCount: 0, - console: {} as Record, - }; - private readonly browser: Browser; - private readonly context: BrowserContext; - public readonly page: Page; - private readonly cdp: CDPSession; - private pendingNetworkDetailReads: Promise[] = []; - - private constructor( - browser: Browser, - context: BrowserContext, - page: Page, - cdp: CDPSession, - options: PlaywrightBrowserOptions, - ) { - this.browser = browser; - this.context = context; - this.page = page; - this.cdp = cdp; - this.page.setDefaultTimeout(options.scenarioTimeoutMs); - this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); - this.page.on('pageerror', () => { - this.diagnostics.pageErrorCount++; - }); - this.page.on('console', message => { - if (this.diagnostics.console[message.type()] == null) { - this.diagnostics.console[message.type()] = 1; - } else { - this.diagnostics.console[message.type()]++; - } - }); - } - - static async create(label: string, options: PlaywrightBrowserOptions): Promise { - process.stderr.write(`[${label}] Launching Playwright Chromium\n`); - const { chromium } = loadPlaywright(); - const browser = await chromium.launch({ - channel: 'chromium', - headless: true, - args: [ - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-background-networking', - '--disable-default-apps', - '--disable-extensions', - '--disable-sync', - '--metrics-recording-only', - '--no-first-run', - '--no-default-browser-check', - '--no-sandbox', - ], - }); - - try { - const context = await browser.newContext({ - baseURL: options.baseUrl, - locale: 'en-US', - }); - await context.addInitScript(() => { - // @ts-expect-error Test-only runtime hint consumed by Misskey frontend code. - window.isPlaywright = true; - }); - - const page = await context.newPage(); - const cdp = await context.newCDPSession(page); - return new HeadlessChromeController(browser, context, page, cdp, options); - } catch (error) { - await browser.close().catch(() => undefined); - throw error; - } - } - - static async with(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise): Promise { - const browser = await HeadlessChromeController.create(label, options); - try { - return await callback(browser); - } finally { - await browser.close(); - } - } - - public async enableNetworkTracking() { - const requests = new Map(); - const webSockets = new Map(); - - const readRequestBody = (row: NetworkRequest) => { - if (!row.hasRequestBody || row.requestBody != null) return; - const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', { - requestId: row.requestId, - }).then(result => { - row.requestBody = result.postData; - }).catch(() => { - // Some requests expose hasPostData but no longer have retrievable body data. - }); - this.pendingNetworkDetailReads.push(pending); - }; - - this.cdp.on('Network.requestWillBeSent', params => { - if (params.request?.url == null) return; - const row: NetworkRequest = { - requestId: params.requestId, - url: params.request.url, - method: params.request.method ?? 'GET', - resourceType: params.type ?? 'Other', - startedAt: params.timestamp ?? 0, - documentUrl: params.documentURL, - requestHeaders: normalizeHeaders(params.request.headers), - requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined, - hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string', - encodedDataLength: 0, - decodedBodyLength: 0, - fromDiskCache: false, - fromServiceWorker: false, - finished: false, - failed: false, - }; - requests.set(params.requestId, row); - this.networkRequests.push(row); - }); - - this.cdp.on('Network.webSocketCreated', params => { - if (params.requestId == null || params.url == null) return; - const row: WebSocketConnection = { - requestId: params.requestId, - url: params.url, - createdAt: params.timestamp ?? 0, - sentFrameCount: 0, - receivedFrameCount: 0, - sentBytes: 0, - receivedBytes: 0, - errorCount: 0, - }; - webSockets.set(params.requestId, row); - this.webSocketConnections.push(row); - }); - - this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); - }); - - this.cdp.on('Network.webSocketHandshakeResponseReceived', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.handshakeResponseStatus = params.response?.status; - row.handshakeResponseStatusText = params.response?.statusText; - row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers); - }); - - this.cdp.on('Network.webSocketFrameSent', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.sentFrameCount += 1; - row.sentBytes += webSocketFramePayloadBytes(params.response); - }); - - this.cdp.on('Network.webSocketFrameReceived', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.receivedFrameCount += 1; - row.receivedBytes += webSocketFramePayloadBytes(params.response); - }); - - this.cdp.on('Network.webSocketFrameError', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.errorCount += 1; - }); - - this.cdp.on('Network.webSocketClosed', params => { - const row = webSockets.get(params.requestId); - if (row == null) return; - row.closedAt = params.timestamp ?? 0; - }); - - this.cdp.on('Network.responseReceived', params => { - const row = requests.get(params.requestId); - if (row == null) return; - row.status = params.response?.status; - row.statusText = params.response?.statusText; - row.mimeType = params.response?.mimeType; - row.responseHeaders = normalizeHeaders(params.response?.headers); - row.protocol = params.response?.protocol; - row.remoteIPAddress = params.response?.remoteIPAddress; - row.remotePort = params.response?.remotePort; - row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders); - row.fromDiskCache = params.response?.fromDiskCache === true; - row.fromServiceWorker = params.response?.fromServiceWorker === true; - }); - - this.cdp.on('Network.dataReceived', params => { - const row = requests.get(params.requestId); - if (row == null) return; - row.decodedBodyLength += params.dataLength ?? 0; - row.encodedDataLength += params.encodedDataLength ?? 0; - }); - - this.cdp.on('Network.loadingFinished', params => { - const row = requests.get(params.requestId); - if (row == null) return; - row.finished = true; - row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); - readRequestBody(row); - }); - - this.cdp.on('Network.loadingFailed', params => { - const row = requests.get(params.requestId); - if (row == null) return; - row.failed = true; - row.finished = true; - row.errorText = params.errorText; - readRequestBody(row); - }); - - await this.cdp.send('Network.enable'); - await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); - await this.cdp.send('Network.setBypassServiceWorker', { bypass: true }); - await this.cdp.send('Page.enable'); - await this.cdp.send('Runtime.enable'); - await this.cdp.send('Performance.enable'); - } - - public async waitForNetworkDetails() { - let settledCount = 0; - while (settledCount < this.pendingNetworkDetailReads.length) { - const pending = this.pendingNetworkDetailReads.slice(settledCount); - settledCount = this.pendingNetworkDetailReads.length; - await Promise.allSettled(pending); - } - } - - public async evaluate(expression: string, timeoutMs = 30_000): Promise { - return await Promise.race([ - this.page.evaluate(expression), - new Promise((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()), - ]) as T; - } - - public collectDiagnostics(): BrowserDiagnostics { - return { - pageErrorCount: this.diagnostics.pageErrorCount, - console: { - log: this.diagnostics.console.log ?? 0, - warning: this.diagnostics.console.warning ?? 0, - error: this.diagnostics.console.error ?? 0, - info: this.diagnostics.console.info ?? 0, - }, - }; - } - - public async collectPerformance(): Promise { - 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])); - const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined); - const tabMemory = await this.collectTabMemory(); - const webVitals = await this.evaluate(`(() => { - const navigation = performance.getEntriesByType('navigation')[0]; - const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime])); - const longTasks = performance.getEntriesByType('longtask'); - const resourceEntries = performance.getEntriesByType('resource'); - return { - firstPaintMs: paintEntries['first-paint'], - firstContentfulPaintMs: paintEntries['first-contentful-paint'], - domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd, - loadEventEndMs: navigation?.loadEventEnd, - longTaskCount: longTasks.length, - longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), - maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0), - resourceEntryCount: resourceEntries.length, - domElements: document.getElementsByTagName('*').length, - }; - })()`); - - return { - cdpMetrics, - runtimeHeap, - tabMemory, - webVitals, - }; - } - - public async collectTabMemory(): Promise { - const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { - const measureMemory = performance.measureUserAgentSpecificMemory; - if (typeof measureMemory !== 'function') return {}; - const result = await measureMemory.call(performance); - return { bytes: result.bytes }; - })()`, 60_000); - - const userAgentSpecificBytes = userAgentSpecificMemory?.bytes; - if (!Number.isFinite(userAgentSpecificBytes)) { - throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes'); - } - - return { - totalBytes: userAgentSpecificBytes as number, - }; - } - - public async takeHeapSnapshot(savePath?: string) { - const chunks: string[] = []; - this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { - chunks.push(params.chunk); - }); - - await this.cdp.send('HeapProfiler.enable'); - await this.cdp.send('HeapProfiler.collectGarbage'); - await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); - - const content = chunks.join(''); - if (savePath != null) { - await writeFile(savePath, content); - } - - return JSON.parse(content); - } - - public async close() { - await this.cdp.detach().catch(() => undefined); - await this.context.close().catch(() => undefined); - await this.browser.close().catch(() => undefined); - } -} - -function isMeasurableRequest(row: NetworkRequest) { - return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:'); -} - -export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary { - const origin = new URL(baseUrl).origin; - const rows = requestRows.filter(isMeasurableRequest); - const byResourceType = {} as NetworkSummary['byResourceType']; - - for (const row of rows) { - const summary = byResourceType[row.resourceType] ?? { - requests: 0, - encodedBytes: 0, - decodedBodyBytes: 0, - }; - summary.requests += 1; - summary.encodedBytes += row.encodedDataLength; - summary.decodedBodyBytes += row.decodedBodyLength; - byResourceType[row.resourceType] = summary; - } - - function isSameOrigin(url: string) { - try { - return new URL(url).origin === origin; - } catch { - return false; - } - } - - return { - requestCount: rows.length, - webSocketConnectionCount: webSocketRows == null - ? rows.filter(row => row.resourceType === 'WebSocket').length - : webSocketRows.length, - webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0, - webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0, - finishedRequestCount: rows.filter(row => row.finished).length, - failedRequestCount: rows.filter(row => row.failed).length, - cachedRequestCount: rows.filter(row => row.fromDiskCache).length, - serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length, - totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0), - totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0), - sameOriginEncodedBytes: rows - .filter(row => isSameOrigin(row.url)) - .reduce((sum, row) => sum + row.encodedDataLength, 0), - thirdPartyEncodedBytes: rows - .filter(row => !isSameOrigin(row.url)) - .reduce((sum, row) => sum + row.encodedDataLength, 0), - byResourceType, - largestRequests: rows - .toSorted((a, b) => b.encodedDataLength - a.encodedDataLength) - .slice(0, 15) - .map(row => ({ - url: row.url, - method: row.method, - resourceType: row.resourceType, - status: row.status, - encodedBytes: row.encodedDataLength, - decodedBodyBytes: row.decodedBodyLength, - })), - failedRequests: rows - .filter(row => row.failed) - .map(row => ({ - url: row.url, - method: row.method, - resourceType: row.resourceType, - errorText: row.errorText, - status: row.status, - })), - }; -} diff --git a/.github/scripts/frontend-browser-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts deleted file mode 100644 index 1f0d74875f..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -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, 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'; - -const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); - -const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812'; -const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); -const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); -const heapSnapshotWorkDirs = { - base: resolve('frontend-browser-base-heap-snapshots'), - head: resolve('frontend-browser-head-heap-snapshots'), -} as const; -const heapSnapshotOutputPaths = { - base: resolve('base-heap-snapshot.heapsnapshot'), - head: resolve('head-heap-snapshot.heapsnapshot'), -} as const; - -type BrowserMeasurementSample = BrowserMeasurement & { - round: number; - networkRequests: NetworkRequest[]; -}; - -type BrowserMetricsReport = { - label: string; - timestamp: string; - url: string; - scenario: string; - sampleCount: number; - aggregation: 'median'; - summary: BrowserMeasurement; - samples: BrowserMeasurementSample[]; -}; - -async function runSignupAndPostScenario(chrome: HeadlessChromeController) { - const page = chrome.page; - const noteText = `Frontend browser metrics ${Date.now()}`; - - await visitHome(page, baseUrl); - await signupThroughUi(page, { username: 'alice', password: 'password' }); - await closeUserSetupDialog(page); - await postNote(page, noteText, 10_000); - - await util.sleep(1000); -} - -function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) { - const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; - if (finiteValues.length === 0) return defaultValue; - return util.median(finiteValues); -} - -function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { - const medianValue = finiteMedian(samples.map(getValue)); - let selected: { sample: BrowserMeasurementSample; distance: number } | null = null; - - for (const sample of samples) { - const value = getValue(sample); - if (!Number.isFinite(value)) continue; - const distance = Math.abs(value - medianValue); - if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) { - selected = { - sample, - distance, - }; - } - } - - return selected?.sample ?? samples[0]; -} - -function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) { - return { - requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)), - encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)), - decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)), - }; -} - -function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary { - const resourceTypes = new Set(); - for (const sample of samples) { - for (const resourceType of Object.keys(sample.network.byResourceType)) { - resourceTypes.add(resourceType); - } - } - - const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); - const byResourceType = {} as NetworkSummary['byResourceType']; - for (const resourceType of resourceTypes) { - byResourceType[resourceType] = summarizeResourceType(samples, resourceType); - } - - return { - requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)), - webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)), - webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)), - webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)), - finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)), - failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)), - cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)), - serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)), - totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)), - totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)), - sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)), - thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)), - byResourceType, - largestRequests: representative.network.largestRequests, - failedRequests: representative.network.failedRequests, - }; -} - -function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] { - const cdpMetricKeys = new Set(); - for (const sample of samples) { - for (const key of Object.keys(sample.performance.cdpMetrics)) { - cdpMetricKeys.add(key); - } - } - - const cdpMetrics = {} as Record; - for (const key of cdpMetricKeys) { - cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key])); - } - - const webVitalKeys = [ - 'firstPaintMs', - 'firstContentfulPaintMs', - 'domContentLoadedEventEndMs', - 'loadEventEndMs', - 'longTaskCount', - 'longTaskDurationMs', - 'maxLongTaskDurationMs', - 'resourceEntryCount', - 'domElements', - ] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[]; - - const webVitals = {} as BrowserMeasurement['performance']['webVitals']; - for (const key of webVitalKeys) { - webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key])); - } - - return { - cdpMetrics, - runtimeHeap: { - usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)), - totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)), - }, - tabMemory: { - totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)), - }, - webVitals, - }; -} - -function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) { - const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( - samples, - sample => sample.heapSnapshot, - { breakdownTopN: heapSnapshotBreakdownTopN }, - ); - if (summary == null) throw new Error('No heap snapshot samples'); - return summary; -} - -function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport { - if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`); - const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); - const summary: BrowserMeasurement = { - label, - 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), - heapSnapshot: summarizeHeapSnapshotSamples(samples), - }; - - return { - label, - timestamp: new Date().toISOString(), - url: baseUrl, - scenario: representative.scenario, - sampleCount: samples.length, - aggregation: 'median', - summary, - samples, - }; -} - -async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) { - await util.prepareInstance(baseUrl); - - return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => { - await chrome.enableNetworkTracking(); - - const startedAt = Date.now(); - await runSignupAndPostScenario(chrome); - const durationMs = Date.now() - startedAt; - await chrome.waitForNetworkDetails(); - const performance = await chrome.collectPerformance(); - const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); - const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }); - const measurement: BrowserMeasurementSample = { - label, - round, - 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, - performance, - heapSnapshot, - }; - - return measurement; - }); -} - -function heapSnapshotPath(label: 'base' | 'head', round: number) { - return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); -} - -async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) { - const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); - await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]); - process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); - await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); -} - -async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) { - let server: ReturnType | null = null; - - try { - server = util.startServer(label, repoDir); - await util.waitForServer(baseUrl, server); - - await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); - await mkdir(heapSnapshotWorkDirs[label], { recursive: true }); - - const samples: BrowserMeasurementSample[] = []; - for (let round = 1; round <= sampleCount; round++) { - process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`); - samples.push(await measureSample( - label, - round, - heapSnapshotPath(label, round), - )); - } - - const report = summarizeSamples(label, samples); - await writeFile(outputPath, JSON.stringify(report, null, '\t')); - process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); - - await saveRepresentativeHeapSnapshot(label, report); - } finally { - if (server != null) await util.stopServer(server); - } -} - -async function main() { - await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); - await genReport('head', resolve(headDirArg), resolve(headOutputArg)); -} - -await main(); diff --git a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts deleted file mode 100644 index 568f4257e2..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts +++ /dev/null @@ -1,446 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; -import * as util from './utility.mts'; -import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-diagnostics.render-md.mts'; -import type { NetworkRequest } from './chrome.mts'; - -type DiffDirection = 'added' | 'removed'; - -type RequestDiff = { - direction: DiffDirection; - round: number; - baseCount: number; - headCount: number; - request: NetworkRequest; -}; - -function escapeHtml(value: unknown) { - return String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function escapeAttribute(value: unknown) { - return escapeHtml(value); -} - -function isHttpRequest(request: NetworkRequest) { - try { - const { protocol } = new URL(request.url); - return protocol === 'http:' || protocol === 'https:'; - } catch { - return false; - } -} - -function requestKey(request: NetworkRequest) { - return [ - request.method, - request.resourceType, - request.url, - ].join('\u0000'); -} - -function groupRequests(requests: NetworkRequest[] | undefined) { - const grouped = new Map(); - for (const request of requests ?? []) { - if (!isHttpRequest(request)) continue; - const key = requestKey(request); - const rows = grouped.get(key) ?? []; - rows.push(request); - grouped.set(key, rows); - } - return grouped; -} - -function byRound(samples: BrowserMeasurementSample[]) { - return new Map(samples.map(sample => [sample.round, sample])); -} - -function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) { - const baseRequests = groupRequests(baseSample?.networkRequests); - const headRequests = groupRequests(headSample?.networkRequests); - const keys = [...new Set([ - ...baseRequests.keys(), - ...headRequests.keys(), - ])].toSorted(); - const diffs: RequestDiff[] = []; - - for (const key of keys) { - const baseRows = baseRequests.get(key) ?? []; - const headRows = headRequests.get(key) ?? []; - if (headRows.length > baseRows.length) { - for (const request of headRows.slice(baseRows.length)) { - diffs.push({ - direction: 'added', - round, - baseCount: baseRows.length, - headCount: headRows.length, - request, - }); - } - } else if (baseRows.length > headRows.length) { - for (const request of baseRows.slice(headRows.length)) { - diffs.push({ - direction: 'removed', - round, - baseCount: baseRows.length, - headCount: headRows.length, - request, - }); - } - } - } - - return diffs; -} - -function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) { - const baseSamples = byRound(base.samples); - const headSamples = byRound(head.samples); - const rounds = [...new Set([ - ...baseSamples.keys(), - ...headSamples.keys(), - ])].toSorted((a, b) => a - b); - return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round))); -} - -function formatMaybeJson(value: string | undefined) { - if (value == null || value === '') return null; - try { - return JSON.stringify(JSON.parse(value), null, '\t'); - } catch { - return value; - } -} - -function formatHeaders(headers: Record | undefined) { - if (headers == null || Object.keys(headers).length === 0) return null; - return JSON.stringify(headers, null, '\t'); -} - -function countBy(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) { - const counts = new Map(); - for (const diff of diffs) { - counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1); - } - return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); -} - -function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) { - const added = diffs.filter(diff => diff.direction === 'added').length; - const removed = diffs.filter(diff => diff.direction === 'removed').length; - const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => ` - - ${escapeHtml(type)} - ${util.formatNumber(count)} - `).join(''); - - return ` -
-
- Base samples - ${util.formatNumber(base.sampleCount)} -
-
- Head samples - ${util.formatNumber(head.sampleCount)} -
-
- Added in Head - ${util.formatNumber(added)} -
-
- Removed in Head - ${util.formatNumber(removed)} -
-
- ${typeRows === '' ? '' : ` -
-

Diffs by Resource Type

- - - ${typeRows} - -
TypeDiff requests
-
`}`; -} - -function renderDetails(title: string, content: string | null, open = false) { - if (content == null || content === '') return ''; - return ` - - ${escapeHtml(title)} -
${escapeHtml(content)}
- `; -} - -function renderRequest(diff: RequestDiff) { - const { request } = diff; - const requestBody = formatMaybeJson(request.requestBody); - const requestHeaders = formatHeaders(request.requestHeaders); - const responseHeaders = formatHeaders(request.responseHeaders); - const bodyNote = requestBody == null && request.hasRequestBody === true - ? '

Request body was present but could not be retrieved from CDP.

' - : ''; - - return ` -
-
- ${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} - ${escapeHtml(request.method)} - ${escapeHtml(request.resourceType)} - ${escapeHtml(request.status ?? '-')} -
- ${escapeHtml(request.url)} -
-
Round
${util.formatNumber(diff.round)}
-
Base count
${util.formatNumber(diff.baseCount)}
-
Head count
${util.formatNumber(diff.headCount)}
-
Encoded
${util.formatBytes(request.encodedDataLength ?? 0)}
-
Decoded body
${util.formatBytes(request.decodedBodyLength ?? 0)}
-
MIME
${escapeHtml(request.mimeType ?? '-')}
-
Protocol
${escapeHtml(request.protocol ?? '-')}
-
Remote
${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}
-
Failed
${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}
-
- ${bodyNote} - ${renderDetails('Request body', requestBody, requestBody != null)} - ${renderDetails('Request headers', requestHeaders)} - ${renderDetails('Response headers', responseHeaders)} -
`; -} - -function renderRound(round: number, diffs: RequestDiff[]) { - const added = diffs.filter(diff => diff.direction === 'added').length; - const removed = diffs.filter(diff => diff.direction === 'removed').length; - return ` -
-

Round ${util.formatNumber(round)}

-

${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed

-
- ${diffs.map(renderRequest).join('\n')} -
-
`; -} - -function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { - const diffs = diffReports(base, head); - const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); - const generatedAt = new Date().toISOString(); - const content = diffs.length === 0 - ? '

No added or removed HTTP(S) requests were found in paired samples.

' - : rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n'); - - return ` - - - - - Frontend Browser Network Request Diff - - - -
-

Frontend Browser Network Request Diff

-

Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

- ${renderSummary(base, head, diffs)} - ${content} -
- - -`; -} - -async function main() { - const [baseFile, headFile, outputFile] = process.argv.slice(2); - - const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; - const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; - await writeFile(outputFile, renderHtml(base, head)); -} - -// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) -if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts deleted file mode 100644 index d90c3d32c1..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ /dev/null @@ -1,297 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -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 { BrowserDiagnostics, NetworkRequest } from './chrome.mts'; - -export type BrowserMeasurement = { - label: string; - timestamp: string; - url: string; - scenario: string; - diagnostics: BrowserDiagnostics; - durationMs: number; - network: { - requestCount: number; - webSocketConnectionCount: number; - webSocketSentBytes: number; - webSocketReceivedBytes: number; - finishedRequestCount: number; - failedRequestCount: number; - cachedRequestCount: number; - serviceWorkerRequestCount: number; - totalEncodedBytes: number; - totalDecodedBodyBytes: number; - sameOriginEncodedBytes: number; - thirdPartyEncodedBytes: number; - byResourceType: Record; - largestRequests: { - url: string; - method: string; - resourceType: string; - status?: number; - encodedBytes: number; - decodedBodyBytes: number; - }[]; - failedRequests: { - url: string; - method: string; - resourceType: string; - errorText?: string; - status?: number; - }[]; - }; - performance: { - cdpMetrics: Record; - runtimeHeap?: { - usedSize: number; - totalSize: number; - }; - tabMemory: { - totalBytes: number; - }; - webVitals: { - firstPaintMs?: number; - firstContentfulPaintMs?: number; - domContentLoadedEventEndMs?: number; - loadEventEndMs?: number; - longTaskCount: number; - longTaskDurationMs: number; - maxLongTaskDurationMs: number; - resourceEntryCount: number; - domElements: number; - }; - }; - heapSnapshot: HeapSnapshotData; -}; - -export type BrowserMeasurementSample = BrowserMeasurement & { - round: number; - networkRequests?: NetworkRequest[]; -}; - -export type BrowserMetricsReport = { - label: string; - timestamp: string; - url: string; - scenario: string; - sampleCount: number; - aggregation: 'median'; - summary: BrowserMeasurement; - samples: BrowserMeasurementSample[]; -}; - -function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { - const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[]; - const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values)))); - if (spread == null) return formatter(value); - return `${formatter(value)}
± ${formatter(spread)}`; -} - -function renderMetricRow( - label: string, - base: BrowserMetricsReport, - head: BrowserMetricsReport, - getSummaryValue: (summary: BrowserMeasurement) => number, - getSampleValue: (sample: BrowserMeasurementSample) => number, - formatter: (value: number) => string, - significantThreshold = 0, - skipIfNotSignificant = true -) { - const baseValue = getSummaryValue(base.summary); - const headValue = getSummaryValue(head.summary); - if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; - - const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); - // 有意な閾値に満たない場合はそもそもrowとして出力しない - if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; - - const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; - //const deltaMedian = `${util.formatColoredDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - const deltaMedian = util.formatColoredDelta(summary.median, formatter, significantThreshold); - - //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter)} |`; - return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter, significantThreshold)} |`; -} - -function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { - return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); -} - -function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { - return resourceTypeBytes(sample, resourceTypes); -} - -function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { - //function getMetric(report: BrowserMeasurement, key: string) { - // return report.performance.cdpMetrics[key]; - //} - - const rows = [ - //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs), - renderMetricRow('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), - renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), - renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), - renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), - renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), - renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), - renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), - //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, util.formatMs), - //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.formatMs), - //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), - //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, util.formatMs), - //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, util.formatMs), - //metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes), - //metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes), - //metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000), - //metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber), - //metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber), - //metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber), - //metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber), - //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), - //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), util.formatSecondsAsMs), - //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs), - renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all), - renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all), - renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all), - renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all), - renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all), - renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all), - renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all), - renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all), - ].filter(row => row != null); - - return [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ...rows, - ].join('\n'); -} - -function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { - const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other']; - const keys = [...new Set([ - ...preferredOrder, - ...Object.keys(base.summary.network.byResourceType), - ...Object.keys(head.summary.network.byResourceType), - ])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null); - - const lines = [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ]; - - for (const key of keys) { - const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; - const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; - lines.push(''); - lines.push(``); - lines.push(``); - lines.push(``); - lines.push(``); - lines.push(``); - lines.push(``); - lines.push(``); - lines.push(''); - } - - lines.push(''); - lines.push('
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
${key}${util.formatNumber(baseRow.requests)}${util.formatNumber(headRow.requests)}${util.formatColoredDelta(headRow.requests - baseRow.requests, util.formatNumber)}${util.formatBytes(baseRow.encodedBytes)}${util.formatBytes(headRow.encodedBytes)}${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}
'); - - return lines.join('\n'); -} - -function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { - return { - summary: report.summary.heapSnapshot, - samples: report.samples.map(sample => ({ - round: sample.round, - data: sample.heapSnapshot, - })), - }; -} - -function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { - baseHeapSnapshotUrl: string; - headHeapSnapshotUrl: string; - detailedHtmlUrl?: string; -}) { - const detailedHtmlUrl = options.detailedHtmlUrl; - const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); - const lines = [ - '## 🖥 Frontend Browser Diagnostics Report', - '', - renderSummaryTable(base, head), - '', - 'Only metrics showing significant changes are displayed.', - '', - detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, - detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', - '
', - 'Requests by resource type', - '', - renderResourceTypeTable(base, head), - '', - '
', - '', - '
', - 'V8 heap snapshot statistics', - '', - heapSnapshotTable ?? '_No V8 heap snapshot data._', - '', - //heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), - //'', - `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, - '
', - '', - ]; - - return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; -} - -async function main() { - const [baseFile, headFile, outputFile] = process.argv.slice(2); - - const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; - const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; - await writeFile(outputFile, renderMd(base, head, { - baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!, - headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!, - detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, - })); -} - -// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) -if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts deleted file mode 100644 index 7e524c82e7..0000000000 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ /dev/null @@ -1,527 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import * as util from './utility.mts'; - -const locale = 'ja-JP'; - -type Manifest = Record; - -const stableNamedChunks = new Set(['vue', 'i18n']); - -type FileEntry = { - comparisonKey: string | null; - displayName: string; - file: string; - manifestKeys: string[]; - size: number; -}; - -type CollectedReport = { - manifest: Manifest; - chunks: FileEntry[]; - comparableChunks: Record; - chunksByManifestKey: Record; - startupFiles: string[]; -}; - -function entryDisplayName(entry: FileEntry) { - if (!entry) return ''; - return entry.displayName || entry.file; -} - -function findEntryKey(manifest: Manifest) { - const entries = Object.entries(manifest); - return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] - ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] - ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] - ?? null; -} - -function stableChunkKey(chunk: Manifest[string]) { - if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; - if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; - return null; -} - -function collectStartupManifestKeys(manifest: Manifest) { - const entryKey = findEntryKey(manifest); - const keys = new Set(); - if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); - - function visit(key: string, importedBy?: string) { - if (keys.has(key)) return; - const chunk = manifest[key]; - const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; - if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); - if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); - if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); - keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey, key); - } - - visit(entryKey); - return keys; -} - -async function resolveBuiltFile(outDir: string, file: string) { - if (file.startsWith('scripts/')) { - const localizedFile = file.slice('scripts/'.length); - const localizedPath = path.join(outDir, locale, localizedFile); - if (await util.fileExists(localizedPath)) { - return { - absolutePath: localizedPath, - relativePath: `${locale}/${localizedFile}`, - }; - } - - throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`); - } - return { - absolutePath: path.join(outDir, file), - relativePath: file, - }; -} - -async function collectReport(repoDir: string): Promise { - const outDir = path.join(repoDir, 'built/_frontend_vite_'); - const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const chunksByFile = new Map(); - const comparableChunks = new Map(); - const chunksByManifestKey = new Map(); - - for (const [manifestKey, chunk] of Object.entries(manifest)) { - if (!chunk.file?.endsWith('.js')) continue; - const builtFile = await resolveBuiltFile(outDir, chunk.file); - const comparisonKey = stableChunkKey(chunk); - let entry = chunksByFile.get(builtFile.relativePath); - if (entry == null) { - entry = { - comparisonKey, - displayName: chunk.src ?? chunk.name ?? manifestKey, - file: builtFile.relativePath, - manifestKeys: [manifestKey], - size: await util.fileSize(builtFile.absolutePath), - }; - chunksByFile.set(entry.file, entry); - } else if (entry.comparisonKey !== comparisonKey) { - throw new Error(`Conflicting identities for ${entry.file}`); - } else { - entry.manifestKeys.push(manifestKey); - } - chunksByManifestKey.set(manifestKey, entry); - if (comparisonKey != null) { - const existing = comparableChunks.get(comparisonKey); - if (existing != null && existing.file !== entry.file) { - throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); - } - comparableChunks.set(comparisonKey, entry); - } - } - - const localeDir = path.join(outDir, locale); - if (await util.fileExists(localeDir)) { - for await (const fullPath of util.traverseDirectory(localeDir)) { - if (!fullPath.endsWith('.js')) continue; - const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (chunksByFile.has(relativePath)) continue; - chunksByFile.set(relativePath, { - comparisonKey: null, - displayName: relativePath, - file: relativePath, - manifestKeys: [], - size: await util.fileSize(fullPath), - }); - } - } - - const startupFiles = new Set(); - for (const manifestKey of collectStartupManifestKeys(manifest)) { - const entry = chunksByManifestKey.get(manifestKey); - if (entry != null) startupFiles.add(entry.file); - } - - return { - manifest, - chunks: [...chunksByFile.values()], - comparableChunks: Object.fromEntries(comparableChunks), - chunksByManifestKey: Object.fromEntries(chunksByManifestKey), - startupFiles: [...startupFiles], - }; -} - -type VisualizerReport = { - nodeParts?: Record; - nodeMetas?: Record; - renderedLength: number; - gzipLength: number; - brotliLength: number; - }>; - options?: Record; -}; - - -function collectVisualizerReport(data: VisualizerReport) { - const nodeParts = data.nodeParts ?? {}; - const nodeMetas = Object.values(data.nodeMetas ?? {}); - const moduleRows = []; - const bundleMap = new Map(); - - for (const meta of nodeMetas) { - const row = { - id: meta.id, - bundles: 0, - renderedLength: 0, - gzipLength: 0, - brotliLength: 0, - importedByCount: meta.importedBy?.length ?? 0, - importedCount: meta.imported?.length ?? 0, - }; - - for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { - const part = nodeParts[partUid]; - if (part == null) continue; - - row.bundles += 1; - row.renderedLength += part.renderedLength; - row.gzipLength += part.gzipLength; - row.brotliLength += part.brotliLength; - - const bundle = bundleMap.get(bundleId) ?? { - id: bundleId, - modules: 0, - renderedLength: 0, - gzipLength: 0, - brotliLength: 0, - }; - bundle.modules += 1; - bundle.renderedLength += part.renderedLength; - bundle.gzipLength += part.gzipLength; - bundle.brotliLength += part.brotliLength; - bundleMap.set(bundleId, bundle); - } - - if (row.bundles > 0) { - moduleRows.push(row); - } - } - - let staticImports = 0; - let dynamicImports = 0; - for (const meta of nodeMetas) { - for (const imported of meta.imported ?? []) { - if (imported.dynamic) { - dynamicImports += 1; - } else { - staticImports += 1; - } - } - } - - const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); - const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); - const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); - const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); - const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); - - return { - options: data.options ?? {}, - summary: { - bundles: bundleRows.length, - modules: moduleRows.length, - entries: nodeMetas.filter((meta) => meta.isEntry).length, - externals: nodeMetas.filter((meta) => meta.isExternal).length, - staticImports, - dynamicImports, - }, - metrics: { - renderedLength: totalRendered, - gzipLength: totalGzip, - brotliLength: totalBrotli, - }, - hotModules, - }; -} - -function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { - const summary = [ - 'bundles', - 'modules', - 'entries', - //'externals', - 'staticImports', - 'dynamicImports', - ] as const; - - const metrics = [ - 'renderedLength', - 'gzipLength', - 'brotliLength', - ] as const; - - return [ - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - `
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before${util.formatNumber(before.summary[key])}${util.formatBytes(before.metrics[key])}
After${util.formatNumber(after.summary[key])}${util.formatBytes(after.metrics[key])}
Δ${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}
Δ (%)${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}
`, - ]; -} - -function getChunkComparisonRows(keys: string[], before: Record, after: Record) { - return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; - return { - key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), - }; - }); -} - -type ChunkComparisonRow = ReturnType[number]; - -type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; -}; - -const smallDeltaThreshold = 5; - -function sumChunkSizes(chunks: FileEntry[]) { - return chunks.reduce((sum, chunk) => sum + chunk.size, 0); -} - -function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); - return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, - }; -} - -function hasSmallDelta(row: ChunkComparisonRow) { - return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; -} - -function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { - return { - beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), - beforeCount: rows.filter(row => row.beforeFile != null).length, - afterCount: rows.filter(row => row.afterFile != null).length, - }; -} - -function comparableMap(chunks: FileEntry[]) { - const entries: [string, FileEntry][] = []; - for (const chunk of chunks) { - if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); - } - return Object.fromEntries(entries); -} - -function summarizeChunkChanges(rows: ReturnType) { - return { - updated: rows.filter((row) => row.changeType === 'updated').length, - added: rows.filter((row) => row.changeType === 'added').length, - removed: rows.filter((row) => row.changeType === 'removed').length, - }; -} - -function formatChunkChangeSummary(label: string, summary: ReturnType) { - return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; -} - -function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { - return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) - || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) - || b.sortSize - a.sortSize - || a.name.localeCompare(b.name); -} - -function chunkFileDisplay(row: ChunkComparisonRow) { - if (row.beforeFile == null) return row.afterFile ?? ''; - if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; - return `${row.beforeFile} → ${row.afterFile}`; -} - -function chunkMarkdownTable( - rows: ReturnType, - total?: { beforeSize: number; afterSize: number }, - generated?: ChunkAggregate, - other?: ChunkAggregate, -) { - const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); - const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); - if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; - - const lines = [ - '| Chunk | Before | After | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - if (total != null) { - lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - lines.push('| | | | | |'); - } - - for (const row of rows) { - const chunkFile = chunkFileDisplay(row); - if (row.changeType === 'added') { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); - } else if (row.changeType === 'removed') { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); - } else { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - } - } - if (hasGenerated) { - lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - } - if (hasOther) { - lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - } - return lines.join('\n'); -} - -function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { - const beforeComparable = before.comparableChunks; - const afterComparable = after.comparableChunks; - const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; - const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); - - const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); - const diffSummary = summarizeChunkChanges(changedRows); - const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), - }; - const diffGenerated = generatedAggregate(before.chunks, after.chunks); - const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta)); - const diffRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする - - const beforeStartupFiles = new Set(before.startupFiles); - const afterStartupFiles = new Set(after.startupFiles); - const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); - const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); - const beforeStartupComparable = comparableMap(beforeStartupChunks); - const afterStartupComparable = comparableMap(afterStartupChunks); - const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; - const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); - const startupSummary = summarizeChunkChanges(startupComparisonRows); - const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); - const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); - const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), - }; - const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); - - return [ - '
', - `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, - '', - chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), - '', - '
', - '', - '
', - `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, - '', - chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), - '', - `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, - '', - '
', - '', - ].join('\n'); -} - -const args = process.argv.slice(2); -const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args; -const before = await collectReport(beforeDir); -const after = await collectReport(afterDir); -const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; -const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; -const beforeVisualizerReport = collectVisualizerReport(beforeStats); -const afterVisualizerReport = collectVisualizerReport(afterStats); -const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`; - -const body = [ - `## 📦 Frontend Bundle Report`, - '', - renderFrontendChunkReport(before, after), - '', - '## Bundle Stats', - '', - renderVisualizerSummaryTable(beforeVisualizerReport, afterVisualizerReport), - '', - visualizerArtifactLink, -].join('\n'); - -await fs.writeFile(outFile, body); diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts deleted file mode 100644 index 92e36188c1..0000000000 --- a/.github/scripts/heap-snapshot-util.mts +++ /dev/null @@ -1,520 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない - -import * as util from './utility.mts'; - -export const heapSnapshotCategory = { - total: { label: 'Total', color: 'gray', colorHex: '#888888' }, - code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, - strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, - jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, - typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, - systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, - otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, - otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, -} as const satisfies Record; - -export type HeapSnapshotData = { - categories: Record; - nodeCounts: Record; - breakdowns?: Record>; -}; - -export type HeapSnapshotReport = { - summary: HeapSnapshotData; - samples: { - round: number; - data: HeapSnapshotData; - }[]; -}; - -export const defaultHeapSnapshotBreakdownTopN = 6; - -export function createEmptyHeapSnapshotData(): HeapSnapshotData { - const categories = {} as HeapSnapshotData['categories']; - const nodeCounts = {} as HeapSnapshotData['nodeCounts']; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - categories[category] = 0; - nodeCounts[category] = 0; - } - return { - categories, - nodeCounts, - breakdowns: {} as HeapSnapshotData['breakdowns'], - }; -} - -function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') { - const label = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (label === '') return fallback; - if (label.length <= 80) return label; - return `${label.slice(0, 77)}...`; -} - -function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) { - if (category === 'strings') return type; - - if (category === 'jsArrays') { - if (type === 'array elements') return 'Array elements'; - if (type === 'object' && name === 'Array') return 'Array objects'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'typedArrays') { - if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'systemObjects') { - if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); - if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'otherJsObjects') { - if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); - return type; - } - - if (category === 'otherNonJsObjects') { - if (type === 'extra native bytes') return 'Extra native bytes'; - if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'code') { - const lowerName = name.toLowerCase(); - if (lowerName.includes('bytecode')) return 'bytecode'; - if (lowerName.includes('builtin')) return 'builtins'; - if (lowerName.includes('regexp')) return 'regexp code'; - if (lowerName.includes('stub')) return 'stubs'; - return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); - } - - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); -} - -export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { - const entries = Object.entries(breakdown) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, topN); - const otherValue = entries - .slice(topN) - .reduce((sum, [, value]) => sum + value, 0); - - const collapsed = Object.fromEntries(topEntries); - if (otherValue > 0) collapsed.Other = otherValue; - return collapsed; -} - -export function collapseHeapSnapshotBreakdowns( - breakdowns: Partial>>, - topN = defaultHeapSnapshotBreakdownTopN, -) { - const collapsed = {} as NonNullable; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - if (category === 'total') continue; - - const categoryBreakdown = breakdowns[category]; - if (categoryBreakdown == null) continue; - - const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); - if (Object.keys(collapsedCategory).length > 0) { - collapsed[category] = collapsedCategory; - } - } - - return collapsed; -} - -// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. -export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { - const meta = snapshot?.snapshot?.meta; - const nodes = snapshot?.nodes; - const edges = snapshot?.edges; - const strings = snapshot?.strings; - if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { - throw new Error('Invalid heap snapshot format'); - } - - const nodeFields = meta.node_fields; - if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); - const edgeFields = meta.edge_fields; - if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); - - const typeOffset = nodeFields.indexOf('type'); - const nameOffset = nodeFields.indexOf('name'); - const selfSizeOffset = nodeFields.indexOf('self_size'); - const edgeCountOffset = nodeFields.indexOf('edge_count'); - if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { - throw new Error('Heap snapshot is missing required node fields'); - } - const edgeTypeOffset = edgeFields.indexOf('type'); - const edgeNameOffset = edgeFields.indexOf('name_or_index'); - const edgeToNodeOffset = edgeFields.indexOf('to_node'); - if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { - throw new Error('Heap snapshot is missing required edge fields'); - } - - const nodeTypeNames = meta.node_types?.[typeOffset]; - if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); - const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; - if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); - - const nodeFieldCount = nodeFields.length; - const edgeFieldCount = edgeFields.length; - const nativeType = nodeTypeNames.indexOf('native'); - const codeType = nodeTypeNames.indexOf('code'); - const hiddenType = nodeTypeNames.indexOf('hidden'); - const stringTypes = new Set([ - nodeTypeNames.indexOf('string'), - nodeTypeNames.indexOf('concatenated string'), - nodeTypeNames.indexOf('sliced string'), - ]); - const internalEdgeType = edgeTypeNames.indexOf('internal'); - const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; - const { categories, nodeCounts } = createEmptyHeapSnapshotData(); - const breakdowns = {} as Record>; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - if (category !== 'total') breakdowns[category] = {}; - } - - function addValue(map: Record, key: string, value: number) { - map[key] = (map[key] ?? 0) + value; - } - - const edgeStartIndexes = new Map(); - const retainerCounts = new Map(); - let edgeIndex = 0; - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - edgeStartIndexes.set(nodeIndex, edgeIndex); - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { - const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; - retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); - } - } - - const jsArrayElementNodeIndexes = new Set(); - - function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { - if (value <= 0) return; - categories[category] += value; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); - if (nodeIndex != null) nodeCounts[category]++; - } - - function addJsArrayElementSize(nodeIndex: number) { - const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { - const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; - if (edgeType !== internalEdgeType) continue; - - const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; - if (edgeName !== 'elements') continue; - - const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; - if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { - const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); - jsArrayElementNodeIndexes.add(elementsNodeIndex); - } - break; - } - } - - if (extraNativeBytes > 0) { - addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); - } - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - const typeId = nodes[nodeIndex + typeOffset]; - const type = nodeTypeNames[typeId] ?? 'unknown'; - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - categories.total += selfSize; - nodeCounts.total++; - - if (typeId === hiddenType) { - addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); - continue; - } - - if (typeId === nativeType) { - if (name === 'system / JSArrayBufferData') { - addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); - } else { - addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); - } - continue; - } - - if (typeId === codeType) { - addCategoryValue('code', selfSize, type, name, nodeIndex); - continue; - } - - if (stringTypes.has(typeId)) { - addCategoryValue('strings', selfSize, type, name, nodeIndex); - continue; - } - - if (name === 'Array') { - addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); - addJsArrayElementSize(nodeIndex); - continue; - } - } - - categories.total += extraNativeBytes; - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; - - const typeId = nodes[nodeIndex + typeOffset]; - if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; - - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - if (name === 'Array') continue; - - const type = nodeTypeNames[typeId] ?? 'unknown'; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); - } - - return { - categories, - nodeCounts, - breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), - }; -} - -function finiteMedian(values: (number | null | undefined)[]) { - const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; - if (finiteValues.length === 0) return null; - return util.median(finiteValues); -} - -export function summarizeHeapSnapshotDataSamples( - samples: T[], - getData: (sample: T) => HeapSnapshotData | null | undefined, - options: { breakdownTopN?: number } = {}, -) { - const data = samples.map(getData); - const categories = {} as HeapSnapshotData['categories']; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); - if (value != null) categories[category] = value; - } - - const nodeCounts = {} as HeapSnapshotData['nodeCounts']; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); - if (value != null) nodeCounts[category] = value; - } - - if (Object.keys(categories).length === 0) return null; - - const breakdowns = {} as NonNullable; - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - if (category === 'total') continue; - - const childKeys = new Set(); - for (const snapshot of data) { - for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) { - childKeys.add(childKey); - } - } - - const categoryBreakdown = {} as Record; - for (const childKey of childKeys) { - const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); - if (value != null) categoryBreakdown[childKey] = value; - } - - const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); - if (Object.keys(collapsed).length > 0) { - breakdowns[category] = collapsed; - } - } - - return { - categories, - nodeCounts, - ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), - }; -} - -function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { - return report.summary.categories[category]; -} - -export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - const baseTotal = getHeapSnapshotCategoryValue(base, 'total'); - const headTotal = getHeapSnapshotCategoryValue(head, 'total'); - - function getHeapSnapshotSampleSpread(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { - const values = report.samples - .map(sample => sample.data.categories[category]) - .filter(value => Number.isFinite(value)) as number[]; - if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); - } - - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - const baseValue = getHeapSnapshotCategoryValue(base, category); - const headValue = getHeapSnapshotCategoryValue(head, category); - const baseSpread = getHeapSnapshotSampleSpread(base, category); - const headSpread = getHeapSnapshotSampleSpread(head, category); - const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => sample.data.categories[category]); - const percent = summary.median * 100 / baseValue; - - if (category === 'total') { - const deltaMedian = `${util.formatDeltaBytes(summary.median, 100000)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - const baseText = `${util.formatBytes(baseValue)}
± ${util.formatBytes(baseSpread)}`; - const headText = `${util.formatBytes(headValue)}
± ${util.formatBytes(headSpread)}`; - const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`); - lines.push('| | | | | | | |'); - } else { - const deltaMedian = util.formatDeltaBytes(summary.median, 100000); - const baseText = util.formatBytes(baseValue); - const headText = util.formatBytes(headValue); - const basePercent = util.formatPercent((baseValue * 100) / baseTotal); - const headPercent = util.formatPercent((headValue * 100) / headTotal); - const metricText = `
$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**${basePercent} → ${headPercent}
`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`); - } - } - - if (lines.length === 2) return null; - return lines.join('\n'); -} - -const heapSnapshotSankeyChildMinRatio = 0.3; -const heapSnapshotSankeyParentMinPercent = 10; - -function escapeCsvValue(value: string) { - return `"${String(value).replaceAll('"', '""')}"`; -} - -export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { - const total = getHeapSnapshotCategoryValue(report, 'total'); - if (total == null || total <= 0) return null; - - function getHeapSnapshotBreakdownEntries(category: keyof typeof heapSnapshotCategory) { - const breakdown = report.summary.breakdowns?.[category]; - if (breakdown == null || typeof breakdown !== 'object') return []; - - return Object.entries(breakdown) - .filter(([, value]) => Number.isFinite(value) && value > 0) - .toSorted((a, b) => b[1] - a[1]); - } - - function formatHeapSnapshotSankeyChildLabel(label: string) { - return String(label).replace(/^[^:]+:\s*/, ''); - } - - function formatSankeyPercentValue(value: number) { - const rounded = Math.round(value * 100) / 100; - if (rounded === 0 && value > 0) return '0.01'; - if (Number.isInteger(rounded)) return String(rounded); - return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); - } - - const categories = (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => { - const value = getHeapSnapshotCategoryValue(report, category); - if (value == null || value <= 0) return null; - const breakdownEntries = getHeapSnapshotBreakdownEntries(category); - const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); - const percent = (value * 100) / total; - const childEntries = []; - let otherPercent = 0; - - if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) { - for (const [childName, childValue] of breakdownEntries) { - const childRatio = childValue / breakdownTotal; - const childPercent = percent * childRatio; - if (childRatio >= heapSnapshotSankeyChildMinRatio) { - childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]); - } else { - otherPercent += childPercent; - } - } - - if (childEntries.length > 0 && otherPercent > 0) { - childEntries.push(['Other', otherPercent]); - } - } - - return { - category, - percent, - childEntries, - }; - }) - .filter(value => value != null); - - if (categories.length === 0) return null; - - const nodeColors = { - [title]: heapSnapshotCategory.total.colorHex, - } as Record; - for (const { category, childEntries } of categories) { - const categoryColor = heapSnapshotCategory[category].colorHex; - nodeColors[category] = categoryColor; - - for (const [childName] of childEntries) { - nodeColors[childName] = categoryColor; - } - } - - const lines = [ - `
${title} heap snapshot composition`, - '', - '```mermaid', - `%%{init: ${JSON.stringify({ - sankey: { - showValues: false, - linkColor: 'target', - labelStyle: 'outlined', - nodeAlignment: 'center', - nodePadding: 10, - nodeColors: { - ...nodeColors, - 'Other': '#888888', - }, - }, - })}}%%`, - 'sankey-beta', - ]; - - for (const { category, percent, childEntries } of categories) { - lines.push(`${escapeCsvValue(title)},${escapeCsvValue(heapSnapshotCategory[category].label)},${formatSankeyPercentValue(percent)}`); - - for (const [childName, childPercent] of childEntries) { - lines.push(`${escapeCsvValue(heapSnapshotCategory[category].label)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); - } - } - - lines.push('```'); - lines.push(''); - lines.push('
'); - - return lines.join('\n'); -} diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts deleted file mode 100644 index c8d7d2914c..0000000000 --- a/.github/scripts/utility.mts +++ /dev/null @@ -1,311 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない - -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -export function sleep(ms: number) { - return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); -} - -export function median(values: number[]) { - const sorted = values.toSorted((a, b) => a - b); - const center = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 1) return sorted[center]; - return Math.round((sorted[center - 1] + sorted[center]) / 2); -} - -export function mad(values: number[]) { - if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); - - const center = median(values); - return median(values.map(value => Math.abs(value - center))); -} - -function getSamplesByRound(samples: T) { - const samplesByRound = new Map(); - for (const sample of samples) { - if (sample.round <= 0) continue; - samplesByRound.set(sample.round, sample); - } - return samplesByRound; -} - -export function pairedDeltaSummary(baseSamples: T, headSamples: T, getValue: (sample: T[number]) => number | null) { - const baseSamplesByRound = getSamplesByRound(baseSamples); - const headSamplesByRound = getSamplesByRound(headSamples); - const values = []; - - for (const [round, baseSample] of baseSamplesByRound) { - const headSample = headSamplesByRound.get(round); - if (headSample == null) continue; - - const baseValue = getValue(baseSample); - const headValue = getValue(headSample); - if (baseValue == null || headValue == null) continue; - - values.push(headValue - baseValue); - } - - return { - median: median(values), - mad: mad(values), - min: Math.min(...values), - max: Math.max(...values), - samples: values.length, - }; -} - -export function normalizePath(filePath: string) { - return filePath.split(path.sep).join('/'); -} - -export async function fileExists(filePath: string) { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -export async function fileSize(filePath: string) { - const stat = await fs.stat(filePath); - return stat.size; -} - -export async function* traverseDirectory(dir: string): AsyncGenerator { - for (const entry of await fs.readdir(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - yield* traverseDirectory(fullPath); - } else if (entry.isFile()) { - yield fullPath; - } - } -} - -export function escapeLatex(text: string) { - return text - .replaceAll('\\', '\\\\') - .replaceAll('{', '\\{') - .replaceAll('}', '\\}') - .replaceAll('%', '\\%'); -} - -export function escapeMdTableCell(value: string) { - return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); -} - -export function formatMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; - return `${formatNumber(value)} ms`; -} - -export function formatSecondsAsMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - return formatMs(value * 1_000); -} - -export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { - if (delta === 0) return text(0); - const sign = delta > 0 ? '+' : '-'; - if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; - const color = delta > 0 ? 'orange' : 'green'; - return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; -} - -const numberFormatter = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 1, -}); - -export function formatNumber(value: number) { - return numberFormatter.format(value); -} - -export function formatBytes(value: number) { - if (value === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let unitIndex = 0; - let size = value; - while (size >= 1000 && unitIndex < units.length - 1) { - size /= 1000; - unitIndex += 1; - } - - const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; - return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; -} - -export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) { - if (before == null || after == null) return '-'; - const delta = after - before; - return formatColoredDelta(delta, v => formatNumber(v), colorThreshold); -} - -export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { - return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold); -} - -export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) { - if (before == null || after == null) return '-'; - const delta = after - before; - return formatDeltaBytes(delta, colorThreshold); -} - -export function formatPercent(value: number) { - return `${formatNumber(value)}%`; -} - -export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { - return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold); -} - -export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) { - if (before == null || before === 0 || after == null || after === 0) return '-'; - const delta = after - before; - return formatDeltaPercent(delta / before * 100, colorThreshold); -} - -export function commandName(command: string) { - if (process.platform !== 'win32') return command; - if (command === 'pnpm') return 'pnpm.cmd'; - return command; -} - -export function readIntegerEnv(name: string, defaultValue: number, min: number) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) { - return new Promise((resolvePromise, reject) => { - const child = spawn(commandName(command), args, { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', data => { - stdout += data; - if (options.logStdout) process.stderr.write(data); - }); - - child.stderr.on('data', data => { - stderr += data; - process.stderr.write(data); - }); - - child.on('error', reject); - - child.on('close', code => { - if (code === 0) { - resolvePromise(stdout); - } else { - reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`)); - } - }); - }); -} - -export function startServer(label: string, repoDir: string) { - process.stderr.write(`[${label}] Starting Misskey test server\n`); - const child = spawn(commandName('pnpm'), ['start:test'], { - cwd: repoDir, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32', - }); - child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); - child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); - return child; -} - -export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) { - const startedAt = Date.now(); - while (Date.now() - startedAt < 120_000) { - if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); - try { - const response = await fetch(`${baseUrl}/`, { redirect: 'manual' }); - if (response.status < 500) return; - } catch { - // retry - } - await sleep(1_000); - } - throw new Error(`Timed out waiting for ${baseUrl}`); -} - -export async function api(baseUrl: string, endpoint: string, body: Record) { - const response = await fetch(`${baseUrl}/api/${endpoint}`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`); - } - if (response.status === 204) return null; - return await response.json(); -} - -export async function prepareInstance(baseUrl: string) { - await api(baseUrl, 'reset-db', {}); - await api(baseUrl, 'admin/accounts/create', { - username: 'admin', - password: 'admin1234', - setupPassword: 'example_password_please_change_this_or_you_will_get_hacked', - }); -} - -export async function stopServer(child: ChildProcessWithoutNullStreams) { - if (child.exitCode != null) return; - - if (process.platform === 'win32') { - spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); - } else if (child.pid != null) { - try { - process.kill(-child.pid, 'SIGTERM'); - } catch { - child.kill('SIGTERM'); - } - } - - await new Promise(resolvePromise => { - if (child.exitCode != null) { - resolvePromise(); - return; - } - child.once('exit', () => resolvePromise()); - setTimeout(() => { - if (child.pid != null) { - try { - if (process.platform === 'win32') { - spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); - } else { - process.kill(-child.pid, 'SIGKILL'); - } - } catch { - child.kill('SIGKILL'); - } - } - resolvePromise(); - }, 10_000).unref(); - }); -} diff --git a/.github/workflows/backend-diagnostics.comment.yml b/.github/workflows/backend-diagnostics.comment.yml index a19166a319..a6d914441c 100644 --- a/.github/workflows/backend-diagnostics.comment.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -19,6 +19,16 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.3 + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.9 + - name: Use Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - name: Install dependencies + run: pnpm --filter diagnostics-backend... install --frozen-lockfile + - name: Download artifacts uses: actions/download-artifact@v8 with: @@ -60,7 +70,11 @@ jobs: env: MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE: ${{ steps.find-heap-snapshot-artifacts.outputs.base-url }} MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifacts.outputs.head-url }} - run: node .github/scripts/backend-diagnostics.render-md.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md + run: >- + pnpm --filter diagnostics-backend run render-md + "$GITHUB_WORKSPACE/artifacts/memory-base.json" + "$GITHUB_WORKSPACE/artifacts/memory-head.json" + "$GITHUB_WORKSPACE/output.md" - uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-num.outputs.pr-number }} @@ -71,6 +85,6 @@ jobs: if: failure() && steps.load-pr-num.outputs.pr-number with: pr-number: ${{ steps.load-pr-num.outputs.pr-number }} - comment-tag: show_memory_diff_error + comment-tag: show_memory_diff message: | An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index d5a47b804f..74469ded63 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -9,10 +9,8 @@ on: paths: - packages/backend/** - packages/misskey-js/** - - .github/scripts/utility.mts - - .github/scripts/backend-diagnostics.render-md.mts - - .github/scripts/backend-diagnostics.inspect.mts - - .github/scripts/memory-stability-util*.mts + - packages-private/diagnostics-shared/** + - packages-private/diagnostics-backend/** - .github/workflows/backend-diagnostics.inspect.yml - .github/workflows/backend-diagnostics.comment.yml @@ -89,11 +87,17 @@ jobs: working-directory: head run: pnpm build - name: Measure backend memory usage + working-directory: head env: MK_MEMORY_COMPARE_ROUNDS: 10 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_HEAP_SNAPSHOT: 1 - run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json + # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} + run: >- + pnpm --filter diagnostics-backend run inspect + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" + "$GITHUB_WORKSPACE/memory-base.json" "$GITHUB_WORKSPACE/memory-head.json" - name: Upload base heap snapshot uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 318999f596..a6d222b6bd 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -13,10 +13,15 @@ jobs: steps: - name: Checkout head uses: actions/checkout@v6.0.3 - - name: Setup Node.js + + - name: setup pnpm + uses: pnpm/action-setup@v6 + + - name: setup node uses: actions/setup-node@v6.4.0 with: node-version-file: '.node-version' + cache: pnpm - name: Checkout base run: | @@ -27,17 +32,16 @@ jobs: git checkout origin/${{ github.base_ref }} CHANGELOG.md - name: Copy to Checker directory for CHANGELOG-base.md - run: cp _base/CHANGELOG.md scripts/changelog-checker/CHANGELOG-base.md + run: cp _base/CHANGELOG.md packages-private/changelog-checker/CHANGELOG-base.md - name: Copy to Checker directory for CHANGELOG-head.md - run: cp CHANGELOG.md scripts/changelog-checker/CHANGELOG-head.md + run: cp CHANGELOG.md packages-private/changelog-checker/CHANGELOG-head.md - name: diff continue-on-error: true run: diff -u CHANGELOG-base.md CHANGELOG-head.md - working-directory: scripts/changelog-checker + working-directory: packages-private/changelog-checker + + - name: Install dependencies + run: pnpm --filter changelog-checker... install --frozen-lockfile - - name: Setup Checker - run: npm install - working-directory: scripts/changelog-checker - name: Run Checker - run: npm run run - working-directory: scripts/changelog-checker + run: pnpm --filter changelog-checker check diff --git a/.github/workflows/frontend-browser-diagnostics.comment.yml b/.github/workflows/frontend-browser-diagnostics.comment.yml deleted file mode 100644 index 5098e618ad..0000000000 --- a/.github/workflows/frontend-browser-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend browser diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend browser diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend browser diagnostics report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - concurrency: - group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Download browser metrics report - uses: actions/download-artifact@v8 - with: - name: frontend-browser-diagnostics - path: ${{ runner.temp }}/frontend-browser-diagnostics - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Load PR number - id: load-pr-number - shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT" - - - name: Comment on pull request - uses: thollander/actions-comment-pull-request@v3 - with: - pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend-browser-diagnostics - file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml deleted file mode 100644 index 92f645ab26..0000000000 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: Frontend browser diagnostics (inspect) - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - packages/frontend/** - - packages/frontend-shared/** - - packages/frontend-builder/** - - packages/backend/** - - packages/i18n/** - - packages/icons-subsetter/** - - packages/misskey-js/** - - packages/misskey-reversi/** - - packages/misskey-bubble-game/** - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .node-version - - .github/misskey/test.yml - - .github/scripts/utility.mts - - .github/scripts/heap-snapshot-util.mts - - .github/scripts/chrome.mts - - .github/scripts/frontend-browser-diagnostics.render-additional-html.mts - - .github/scripts/frontend-browser-diagnostics.render-md.mts - - .github/scripts/frontend-browser-diagnostics.inspect.mts - - .github/workflows/frontend-browser-diagnostics.inspect.yml - - .github/workflows/frontend-browser-diagnostics.comment.yml - -permissions: - contents: read - pull-requests: read - -concurrency: - group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - report: - name: Measure frontend browser metrics - runs-on: ubuntu-latest - timeout-minutes: 90 - - services: - postgres: - image: postgres:18 - ports: - - 54312:5432 - env: - POSTGRES_DB: test-misskey - POSTGRES_HOST_AUTH_METHOD: trust - redis: - image: redis:8 - ports: - - 56312:6379 - - steps: - - name: Checkout base - uses: actions/checkout@v6.0.3 - with: - persist-credentials: false - repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.event.pull_request.base.sha }} - path: before - submodules: true - - - name: Checkout pull request - uses: actions/checkout@v6.0.3 - with: - persist-credentials: false - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - path: after - submodules: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 - with: - package_json_file: after/package.json - - - name: Setup Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version-file: after/.node-version - cache: pnpm - cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml - - - name: Install dependencies for base - working-directory: before - run: pnpm i --frozen-lockfile - - - name: Configure base - working-directory: before - run: cp .github/misskey/test.yml .config - - - name: Build base - working-directory: before - run: pnpm build - - - name: Install dependencies for pull request - working-directory: after - run: pnpm i --frozen-lockfile - - - name: Configure pull request - working-directory: after - run: cp .github/misskey/test.yml .config - - - name: Build pull request - working-directory: after - run: pnpm build - - - name: Install Playwright browsers - working-directory: after/packages/frontend - run: pnpm exec playwright install --with-deps --no-shell chromium - - - name: Measure frontend browser metrics - shell: bash - env: - FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 - MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - mkdir -p "$REPORT_DIR" - node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" - - - name: Upload browser base heap snapshot - id: upload-browser-base-heap-snapshot - uses: actions/upload-artifact@v7 - with: - path: base-heap-snapshot.heapsnapshot - archive: false - if-no-files-found: error - retention-days: 7 - - - name: Upload browser head heap snapshot - id: upload-browser-head-heap-snapshot - uses: actions/upload-artifact@v7 - with: - path: head-heap-snapshot.heapsnapshot - archive: false - if-no-files-found: error - retention-days: 7 - - - name: Generate browser detailed html - shell: bash - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" - - - name: Upload browser detailed html - id: upload-browser-detailed-html - uses: actions/upload-artifact@v7 - with: - name: frontend-browser-metrics-detailed-html - path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html - if-no-files-found: error - archive: false - retention-days: 7 - - - name: Generate browser metrics report - shell: bash - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }} - FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} - FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/before-browser.json" - test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-diagnostics.render-md.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-diagnostics.md" - printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" - printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" - printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" - printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - - - name: Check browser metrics report - shell: bash - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" - test -s "$REPORT_DIR/frontend-browser-diagnostics.md" - test -s "$REPORT_DIR/frontend-browser-detailed-html.html" - test -s "$REPORT_DIR/pr-number.txt" - test -s "$REPORT_DIR/head-sha.txt" - cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload browser metrics report - uses: actions/upload-artifact@v7 - with: - name: frontend-browser-diagnostics - path: | - ${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json - ${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json - ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md - ${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt - ${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt - ${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt - ${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/frontend-bundle-diagnostics.comment.yml b/.github/workflows/frontend-bundle-diagnostics.comment.yml deleted file mode 100644 index 709c735e69..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.comment.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Frontend bundle diagnostics (comment) - -on: - workflow_run: - workflows: - - Frontend bundle diagnostics (inspect) - types: - - completed - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - comment: - name: Comment frontend bundle report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - concurrency: - group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }} - cancel-in-progress: true - steps: - - name: Download bundle report - uses: actions/download-artifact@v8 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - - - name: Load PR number - id: load-pr-number - shell: bash - run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT" - - - name: Comment on pull request - uses: thollander/actions-comment-pull-request@v3 - with: - pr-number: ${{ steps.load-pr-number.outputs.pr-number }} - comment-tag: frontend-bundle-diagnostics - file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml deleted file mode 100644 index 6aa4f54fb4..0000000000 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Frontend bundle diagnostics (inspect) - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - paths: - - packages/frontend/** - - packages/frontend-shared/** - - packages/frontend-builder/** - - packages/i18n/** - - packages/icons-subsetter/** - - packages/misskey-js/** - - packages/misskey-reversi/** - - packages/misskey-bubble-game/** - - package.json - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .node-version - - .github/scripts/utility.mts - - .github/scripts/frontend-bundle-diagnostics.render-md.mts - - .github/workflows/frontend-bundle-diagnostics.inspect.yml - - .github/workflows/frontend-bundle-diagnostics.comment.yml - -permissions: - contents: read - pull-requests: read - -concurrency: - group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - report: - name: Build frontend bundle report - runs-on: ubuntu-latest - steps: - - name: Checkout base - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.base.repo.full_name }} - ref: ${{ github.event.pull_request.base.sha }} - path: before - submodules: true - - - name: Checkout pull request - uses: actions/checkout@v6.0.3 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - path: after - submodules: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 - with: - package_json_file: after/package.json - - - name: Setup Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version-file: after/.node-version - cache: pnpm - cache-dependency-path: | - before/pnpm-lock.yaml - after/pnpm-lock.yaml - - - name: Install dependencies for base - working-directory: before - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for base - working-directory: before - run: pnpm --filter "frontend^..." run build - - - name: Prepare report output - run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report" - - - name: Build frontend report for base - working-directory: before - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json - run: pnpm --filter frontend run build - - - name: Install dependencies for pull request - working-directory: after - run: pnpm i --frozen-lockfile - - - name: Build frontend dependencies for pull request - working-directory: after - run: pnpm --filter "frontend^..." run build - - - name: Build frontend report for pull request - working-directory: after - env: - FRONTEND_BUNDLE_VISUALIZER: 'true' - FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json - FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - run: pnpm --filter frontend run build - - - name: Upload bundle visualizer - id: upload-bundle-visualizer - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-visualizer - path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html - if-no-files-found: error - archive: false - retention-days: 7 - - - name: Generate report markdown - shell: bash - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_NUMBER: ${{ github.event.pull_request.number }} - FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-report.md" - printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" - printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" - printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" - printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" - - - name: Check report - run: | - REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - test -s "$REPORT_DIR/before-stats.json" - test -s "$REPORT_DIR/after-stats.json" - test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md" - cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload bundle report - uses: actions/upload-artifact@v7 - with: - name: frontend-bundle-report - path: ${{ runner.temp }}/frontend-bundle-report/ - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/frontend-diagnostics.comment.yml b/.github/workflows/frontend-diagnostics.comment.yml new file mode 100644 index 0000000000..96deddcc58 --- /dev/null +++ b/.github/workflows/frontend-diagnostics.comment.yml @@ -0,0 +1,75 @@ +name: Frontend diagnostics (comment) + +on: + workflow_run: + workflows: + - Frontend diagnostics (inspect) + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend diagnostics report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-diagnostics-report-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download frontend diagnostics report + uses: actions/download-artifact@v8 + with: + name: frontend-diagnostics + path: ${{ runner.temp }}/frontend-diagnostics + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Resolve current pull request + id: resolve-pr + uses: actions/github-script@v9 + env: + PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + with: + script: | + const fs = require('fs/promises'); + const { owner, repo } = context.repo; + const workflowRun = context.payload.workflow_run; + const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim(); + if (!/^[1-9]\d*$/.test(pullRequestNumberText)) { + throw new Error('Invalid pull request number in frontend diagnostics artifact.'); + } + + const pullRequestNumber = Number(pullRequestNumberText); + if (!Number.isSafeInteger(pullRequestNumber)) { + throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.'); + } + + const { data: currentPullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }); + + if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) { + core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`); + core.setOutput('is-current', 'false'); + return; + } + + core.setOutput('is-current', 'true'); + core.setOutput('pr-number', String(pullRequestNumber)); + + - name: Comment on pull request + if: steps.resolve-pr.outputs.is-current == 'true' + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.resolve-pr.outputs.pr-number }} + comment-tag: frontend-diagnostics + file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md diff --git a/.github/workflows/frontend-diagnostics.inspect.yml b/.github/workflows/frontend-diagnostics.inspect.yml new file mode 100644 index 0000000000..ecf06c3092 --- /dev/null +++ b/.github/workflows/frontend-diagnostics.inspect.yml @@ -0,0 +1,246 @@ +name: Frontend diagnostics (inspect) + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-builder/** + - packages/backend/** + - packages/i18n/** + - packages/icons-subsetter/** + - packages/misskey-js/** + - packages/misskey-reversi/** + - packages/misskey-bubble-game/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .node-version + - .github/misskey/test.yml + - packages-private/diagnostics-shared/** + - packages-private/diagnostics-frontend/** + - .github/workflows/frontend-diagnostics.inspect.yml + - .github/workflows/frontend-diagnostics.comment.yml + +permissions: + contents: read + pull-requests: read + +concurrency: + group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + report: + name: Measure frontend diagnostics + runs-on: ubuntu-latest + timeout-minutes: 90 + + services: + postgres: + image: postgres:18 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:8 + ports: + - 56312:6379 + + steps: + - name: Checkout base + uses: actions/checkout@v6.0.3 + with: + persist-credentials: false + repository: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.event.pull_request.base.sha }} + path: base + submodules: true + + - name: Checkout head + uses: actions/checkout@v6.0.3 + with: + persist-credentials: false + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + path: head + submodules: true + + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.9 + with: + package_json_file: head/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: head/.node-version + cache: pnpm + cache-dependency-path: | + base/pnpm-lock.yaml + head/pnpm-lock.yaml + + - name: Prepare report output + shell: bash + run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics" + + - name: Install dependencies for base + working-directory: base + run: pnpm i --frozen-lockfile + + - name: Configure base + working-directory: base + run: cp .github/misskey/test.yml .config + + - name: Build base + working-directory: base + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json + run: pnpm build + + - name: Install dependencies for head + working-directory: head + run: pnpm i --frozen-lockfile + + - name: Configure head + working-directory: head + run: cp .github/misskey/test.yml .config + + - name: Build head + working-directory: head + env: + FRONTEND_BUNDLE_VISUALIZER: 'true' + FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html + run: pnpm build + + - name: Upload bundle visualizer + id: upload-bundle-visualizer + uses: actions/upload-artifact@v7 + with: + name: frontend-bundle-visualizer + path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html + if-no-files-found: error + archive: false + retention-days: 7 + + - name: Install Playwright browsers + working-directory: head/packages-private/diagnostics-frontend + run: pnpm exec playwright install --with-deps --no-shell chromium + + - name: Measure frontend browser metrics + shell: bash + working-directory: head + env: + FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 + MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" + # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + pnpm --filter diagnostics-frontend run inspect-browser \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" + + - name: Upload browser base heap snapshot + id: upload-browser-base-heap-snapshot + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-base-heap-snapshot + path: base-heap-snapshot.heapsnapshot + archive: false + if-no-files-found: error + retention-days: 7 + + - name: Upload browser head heap snapshot + id: upload-browser-head-heap-snapshot + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-head-heap-snapshot + path: head-heap-snapshot.heapsnapshot + archive: false + if-no-files-found: error + retention-days: 7 + + - name: Generate browser detailed html + shell: bash + working-directory: head + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-browser-html \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ + "$REPORT_DIR/frontend-browser-detailed-html.html" + + - name: Upload browser detailed html + id: upload-browser-detailed-html + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-detailed-html + path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html + if-no-files-found: error + archive: false + retention-days: 7 + + - name: Generate frontend diagnostics report + shell: bash + working-directory: head + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} + FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }} + FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} + FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/base-bundle-stats.json" + test -s "$REPORT_DIR/head-bundle-stats.json" + test -s "$REPORT_DIR/base-browser.json" + test -s "$REPORT_DIR/head-browser.json" + pnpm --filter diagnostics-frontend run render-md \ + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \ + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \ + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \ + "$REPORT_DIR/frontend-diagnostics.md" + printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt" + printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt" + printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt" + printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" + + - name: Check frontend diagnostics report + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics" + test -s "$REPORT_DIR/frontend-diagnostics.md" + test -s "$REPORT_DIR/frontend-browser-detailed-html.html" + test -s "$REPORT_DIR/pr-number.txt" + test -s "$REPORT_DIR/head-sha.txt" + cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload frontend diagnostics report + uses: actions/upload-artifact@v7 + with: + name: frontend-diagnostics + path: | + ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json + ${{ runner.temp }}/frontend-diagnostics/base-browser.json + ${{ runner.temp }}/frontend-diagnostics/head-browser.json + ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md + ${{ runner.temp }}/frontend-diagnostics/pr-number.txt + ${{ runner.temp }}/frontend-diagnostics/base-sha.txt + ${{ runner.temp }}/frontend-diagnostics/head-sha.txt + ${{ runner.temp }}/frontend-diagnostics/pr-url.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml new file mode 100644 index 0000000000..cf7e50670a --- /dev/null +++ b/.github/workflows/packages-private.yml @@ -0,0 +1,52 @@ +name: Lint and test packages-private + +on: + push: + branches: + - master + - develop + paths: + - packages-private/** + - packages/shared/eslint.config.js + - package.json + - pnpm-workspace.yaml + - pnpm-lock.yaml + - .github/workflows/packages-private.yml + pull_request: + paths: + - packages-private/** + - packages/shared/eslint.config.js + - package.json + - pnpm-workspace.yaml + - pnpm-lock.yaml + - .github/workflows/packages-private.yml + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workspace: + - diagnostics-shared + - diagnostics-backend + - diagnostics-frontend + - changelog-checker + steps: + - uses: actions/checkout@v6.0.3 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.9 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - run: pnpm --filter ${{ matrix.workspace }}... install --frozen-lockfile + # lint = typecheck + eslint + - run: pnpm --filter ${{ matrix.workspace }} run lint + # 各パッケージは必ず test スクリプトを持たせる (無いと ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT で落ちる) + - run: pnpm --filter ${{ matrix.workspace }} run test diff --git a/.okteto/okteto-pipeline.yml b/.okteto/okteto-pipeline.yml deleted file mode 100644 index e2996fbbc9..0000000000 --- a/.okteto/okteto-pipeline.yml +++ /dev/null @@ -1,6 +0,0 @@ -build: - misskey: - args: - - NODE_ENV=development -deploy: - - helm upgrade --install misskey chart --set image=${OKTETO_BUILD_MISSKEY_IMAGE} --set url="https://misskey-$(kubectl config view --minify -o jsonpath='{..namespace}').cloud.okteto.net" --set environment=development diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff98a1a05..be37bb7631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ - Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正 - Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善) - Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正 +- Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正 +- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正 ### Server - Feat: OpenTelemetryサポート @@ -52,16 +54,19 @@ - ジョブキュー(エンキュー元のトレースを含む) - Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように - Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804) -- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように +- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Enhance: URLプレビューの結果を内部でキャッシュするように - Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持) - Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように - Enhance: バックエンドのログを1行JSON形式で出力できるように +- Enhance: OpenTelemetryのTrace Contextを構造化ログへ関連付けられるように - Fix: `/stats` API のレスポンス型が正しくない問題を修正 - Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 - Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 - Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように +- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正 +- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正 ## 2026.6.0 diff --git a/chart/Chart.yaml b/chart/Chart.yaml deleted file mode 100644 index d151f1dd17..0000000000 --- a/chart/Chart.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v2 -name: misskey -version: 0.0.0 -description: This chart is created for the purpose of previewing Pull Requests. Do not use this for production use. diff --git a/chart/files/default.yml b/chart/files/default.yml deleted file mode 100644 index 61fa6d6f0c..0000000000 --- a/chart/files/default.yml +++ /dev/null @@ -1,231 +0,0 @@ -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Misskey configuration -#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -# ┌─────┐ -#───┘ URL └───────────────────────────────────────────────────── - -# Final accessible URL seen by a user. -# url: https://example.tld/ - -# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE -# URL SETTINGS AFTER THAT! - -# ┌───────────────────────┐ -#───┘ Port and TLS settings └─────────────────────────────────── - -# -# Misskey supports two deployment options for public. -# - -# Option 1: With Reverse Proxy -# -# +----- https://example.tld/ ------------+ -# +------+ |+-------------+ +----------------+| -# | User | ---> || Proxy (443) | ---> | Misskey (3000) || -# +------+ |+-------------+ +----------------+| -# +---------------------------------------+ -# -# You need to setup reverse proxy. (eg. nginx) -# You do not define 'https' section. - -# Option 2: Standalone -# -# +- https://example.tld/ -+ -# +------+ | +---------------+ | -# | User | ---> | | Misskey (443) | | -# +------+ | +---------------+ | -# +------------------------+ -# -# You need to run Misskey as root. -# You need to set Certificate in 'https' section. - -# To use option 1, uncomment below line. -port: 3000 # A port that your Misskey server should listen. - -# To use option 2, uncomment below lines. -#port: 443 - -#https: -# # path for certification -# key: /etc/letsencrypt/live/example.tld/privkey.pem -# cert: /etc/letsencrypt/live/example.tld/fullchain.pem - -# ┌──────────────────────────┐ -#───┘ PostgreSQL configuration └──────────────────────────────── - -db: - host: localhost - port: 5432 - - # Database name - db: misskey - - # Auth - user: example-misskey-user - pass: example-misskey-pass - - # Whether disable Caching queries - #disableCache: true - - # Extra Connection options - #extra: - # ssl: true - -dbReplications: false - -# You can configure any number of replicas here -#dbSlaves: -# - -# host: -# port: -# db: -# user: -# pass: -# - -# host: -# port: -# db: -# user: -# pass: - -# ┌─────────────────────┐ -#───┘ Redis configuration └───────────────────────────────────── - -redis: - host: localhost - port: 6379 - #family: 0 # 0=Both, 4=IPv4, 6=IPv6 - #pass: example-pass - #prefix: example-prefix - #db: 1 - -#redisForPubsub: -# host: localhost -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - -#redisForJobQueue: -# host: localhost -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - -#redisForTimelines: -# host: redis -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - -#redisForReactions: -# host: redis -# port: 6379 -# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 -# #pass: example-pass -# #prefix: example-prefix -# #db: 1 - -# ┌───────────────────────────┐ -#───┘ MeiliSearch configuration └───────────────────────────── - -#meilisearch: -# host: localhost -# port: 7700 -# apiKey: '' -# ssl: true -# index: '' - -# ┌───────────────┐ -#───┘ ID generation └─────────────────────────────────────────── - -# You can select the ID generation method. -# You don't usually need to change this setting, but you can -# change it according to your preferences. - -# Available methods: -# aid ... Short, Millisecond accuracy -# aidx ... Millisecond accuracy -# meid ... Similar to ObjectID, Millisecond accuracy -# ulid ... Millisecond accuracy -# objectid ... This is left for backward compatibility - -# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE -# ID SETTINGS AFTER THAT! - -id: "aidx" - -# ┌────────────────┐ -#───┘ Error tracking └────────────────────────────────────────── - -# Sentry is available for error tracking. -# See the Sentry documentation for more details on options. - -#sentryForBackend: -# enableNodeProfiling: true -# options: -# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' - -#sentryForFrontend: -# vueIntegration: -# tracingOptions: -# trackComponents: true -# browserTracingIntegration: -# replayIntegration: -# options: -# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' - -# ┌─────────────────────┐ -#───┘ Other configuration └───────────────────────────────────── - -# Whether disable HSTS -#disableHsts: true - -# Number of worker processes -#clusterLimit: 1 - -# Number of threads of extra thread pool for CPU-intensive tasks (per worker) -#threadPoolSize: 1 - -# Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 - -# Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 - -# Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 - -# IP address family used for outgoing request (ipv4, ipv6 or dual) -#outgoingAddressFamily: ipv4 - -# Proxy for HTTP/HTTPS -#proxy: http://127.0.0.1:3128 - -#proxyBypassHosts: [ -# 'example.com', -# '192.0.2.8' -#] - -# Proxy for SMTP/SMTPS -#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT -#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 -#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 - -# Media Proxy -#mediaProxy: https://example.com/proxy - -allowedPrivateNetworks: - - '127.0.0.1/32' - -# Upload or download file size limits (bytes) -#maxFileSize: 262144000 diff --git a/chart/templates/ConfigMap.yml b/chart/templates/ConfigMap.yml deleted file mode 100644 index 37c25e0864..0000000000 --- a/chart/templates/ConfigMap.yml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "misskey.fullname" . }}-configuration -data: - default.yml: |- - {{ .Files.Get "files/default.yml"|nindent 4 }} - url: {{ .Values.url }} diff --git a/chart/templates/Deployment.yml b/chart/templates/Deployment.yml deleted file mode 100644 index 280bf9a597..0000000000 --- a/chart/templates/Deployment.yml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "misskey.fullname" . }} - labels: - {{- include "misskey.labels" . | nindent 4 }} -spec: - selector: - matchLabels: - {{- include "misskey.selectorLabels" . | nindent 6 }} - replicas: 1 - template: - metadata: - labels: - {{- include "misskey.selectorLabels" . | nindent 8 }} - spec: - containers: - - name: misskey - image: {{ .Values.image }} - env: - - name: NODE_ENV - value: {{ .Values.environment }} - volumeMounts: - - name: {{ include "misskey.fullname" . }}-configuration - mountPath: /misskey/.config - readOnly: true - ports: - - containerPort: 3000 - - name: postgres - image: postgres:18-alpine - env: - - name: POSTGRES_USER - value: "example-misskey-user" - - name: POSTGRES_PASSWORD - value: "example-misskey-pass" - - name: POSTGRES_DB - value: "misskey" - ports: - - containerPort: 5432 - - name: redis - image: redis:7-alpine - ports: - - containerPort: 6379 - volumes: - - name: {{ include "misskey.fullname" . }}-configuration - configMap: - name: {{ include "misskey.fullname" . }}-configuration diff --git a/chart/templates/Service.yml b/chart/templates/Service.yml deleted file mode 100644 index afd851a9f1..0000000000 --- a/chart/templates/Service.yml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "misskey.fullname" . }} - annotations: - dev.okteto.com/auto-ingress: "true" -spec: - type: ClusterIP - ports: - - port: 3000 - protocol: TCP - name: http - selector: - {{- include "misskey.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl deleted file mode 100644 index a5a2499f3f..0000000000 --- a/chart/templates/_helpers.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "misskey.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "misskey.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "misskey.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "misskey.labels" -}} -helm.sh/chart: {{ include "misskey.chart" . }} -{{ include "misskey.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "misskey.selectorLabels" -}} -app.kubernetes.io/name: {{ include "misskey.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "misskey.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "misskey.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} diff --git a/chart/values.yml b/chart/values.yml deleted file mode 100644 index a7031538a9..0000000000 --- a/chart/values.yml +++ /dev/null @@ -1,3 +0,0 @@ -url: https://example.tld/ -image: okteto.dev/misskey -environment: production diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md deleted file mode 100644 index 3a8c404a3e..0000000000 --- a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md +++ /dev/null @@ -1,176 +0,0 @@ -# Frontend bundle report: generated chunk handling - -## Background - -The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`. - -Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:`, so later entries overwrite earlier entries. This produces two problems: - -1. unrelated chunks can be compared as if they were the same chunk; and -2. overwritten chunks are omitted from the reported total. - -Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching. - -## Goals - -- Never compare unrelated generated chunks as the same chunk. -- Keep generated chunks out of the individual chunk-diff rows. -- Preserve the contribution of every physical JavaScript chunk in totals. -- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. -- Apply the same rules to the full chunk report and the startup chunk report. -- Continue comparing source-backed chunks and intentionally named chunks. - -## Non-goals - -- Inferring chunk identity from module-set similarity. -- Preserving an `updated` relationship for every shared chunk after code splitting changes. -- Changing the frontend's code-splitting strategy or output filenames. -- Suppressing specific names such as `esm` or `dist` with a denylist. - -## Approaches considered - -### Denylist generated-looking names - -Exclude names such as `esm`, `dist`, `lib`, and `index`. - -This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place. - -### Compare only stable identities and aggregate the rest - -Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate. - -This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible. - -### Match shared chunks by their module sets - -Use visualizer metadata to compare exact or similar sets of module IDs. - -This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient. - -## Design - -### Separate physical chunks from comparable identities - -`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection. - -Each physical chunk contains: - -- its resolved relative output path; -- its byte size; -- its manifest key, when present; -- its display name; and -- an optional comparison key. - -The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds. - -### Comparison-key classification - -A chunk receives a comparison key only in one of these cases: - -1. `chunk.src` is present: use `src:`. -2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:`. - -The initial stable-name allowlist is: - -- `vue` -- `i18n` - -These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`. - -All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key. - -If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison. - -### Full chunk report - -The report computes three independent values: - -1. **Total:** the sum of every unique physical JavaScript chunk. -2. **Generated chunk aggregate:** the sum of chunks without a comparison key. -3. **Individual rows:** before/after comparisons for stable comparison keys only. - -The table starts with the existing `(total)` row. Generated chunks are rendered at the bottom of the table as one aggregate row such as: - -```text -(other generated chunks) -``` - -This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. No additional generated-chunk count note is rendered below the table. - -### Small-delta aggregation - -Stable comparison rows whose absolute byte delta is at most `5 B` are grouped into an `(other)` aggregate instead of being rendered individually. The threshold is inclusive: deltas from `-5 B` through `+5 B` are grouped, while a `6 B` absolute delta remains an individual row. Small additions and removals are handled by the same rule. - -The `(other)` row reports the sum of the grouped chunks' before sizes and the sum of their after sizes. It does not expose an arbitrary representative filename. In the full chunk report, only changed rows are candidates because unchanged rows are already omitted. In the startup report, all rows currently eligible for display are candidates, so unchanged rows are also grouped instead of being listed individually. - -The updated/added/removed counts are calculated before small-delta rows are grouped. Therefore the summary continues to include every stable changed chunk, including chunks represented only by `(other)`. - -Table rows are ordered as follows: - -1. `(total)`; -2. one empty separator row; -3. individual stable comparison rows whose absolute delta is greater than `5 B`; -4. `(other generated chunks)`, when generated chunks exist; and -5. `(other)`, when small-delta stable chunks exist. - -There is no additional separator row before the aggregate rows. If no individual stable row exists, the empty row after `(total)` is therefore immediately followed by the aggregate rows. - -The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates. - -### Startup chunk report - -Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. - -Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table. - -### Displayed filenames - -For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file. - -The display can use one filename when unchanged and `before → after` when the content-hashed filename differs. - -The generated aggregate row does not display an arbitrary representative filename. - -## Data flow - -1. Read each build's Vite manifest. -2. Resolve localized output files and collect unique physical chunks. -3. Attach optional stable comparison keys using the classification rules. -4. Traverse startup imports using manifest identities and map them to physical chunks. -5. Calculate full and startup totals from physical chunks. -6. Calculate generated aggregates from chunks without comparison keys. -7. Compare only unique stable keys for individual rows. -8. Render totals, generated aggregates, and stable rows. - -## Error handling - -- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error. -- Duplicate physical output paths are de-duplicated and counted once. -- Duplicate stable comparison keys fail with an error that includes the key and conflicting files. -- Missing or malformed manifest data remains fatal rather than producing a partial report. - -## Testing - -Add focused fixtures or pure-function tests covering: - -- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate; -- `dist` and `esm` chunks never produce individual comparison rows; -- source-backed chunks with the same `src` are reported as updated; -- source-backed additions and removals remain visible; -- allowlisted `vue` and `i18n` chunks remain individually comparable; -- duplicate stable keys produce a descriptive error instead of overwriting; -- full totals equal the sum of all unique physical chunks; -- startup totals include multiple same-name generated chunks exactly once each; -- generated aggregate changes do not affect the updated/added/removed summary counts; -- differing before/after filenames are rendered without attributing both sizes to one file; -- deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual; -- small updated, added, and removed chunks remain included in the summary counts; -- an empty separator row appears immediately after `(total)`, with no additional separator before `(other generated chunks)` or `(other)`; -- no generated-chunk grouping note is rendered below the table; and -- the same small-delta and ordering rules apply to the full and startup tables. - -Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. - -## Future extension - -If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity. diff --git a/package.json b/package.json index 5a01454ad2..8bb49f141e 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,26 @@ { "name": "misskey", - "version": "2026.7.0-alpha.6", + "version": "2026.7.0-beta.1", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@11.11.0", "workspaces": [ - "packages/misskey-js", + "packages/backend", + "packages/frontend-shared", + "packages/frontend", + "packages/frontend-builder", + "packages/frontend-embed", "packages/i18n", + "packages/icons-subsetter", + "packages/sw", + "packages/misskey-js", + "packages/misskey-js/generator", "packages/misskey-reversi", "packages/misskey-bubble-game", - "packages/icons-subsetter", - "packages/frontend-shared", - "packages/frontend-builder", - "packages/sw", - "packages/backend", - "packages/frontend", - "packages/frontend-embed" + "packages-private/*" ], "private": true, "scripts": { @@ -55,22 +57,22 @@ "esbuild": "0.28.1", "execa": "9.6.1", "ignore-walk": "9.0.0", - "js-yaml": "5.2.0", - "tar": "7.5.19" + "js-yaml": "5.2.1", + "tar": "7.5.20" }, "devDependencies": { - "@eslint/js": "9.39.4", + "@eslint/js": "9.39.5", "@misskey-dev/eslint-plugin": "2.1.0", - "@types/node": "26.0.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@typescript/native-preview": "7.0.0-dev.20260426.1", + "@types/node": "26.1.1", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript/native": "npm:typescript@7.0.2", "cross-env": "10.1.0", - "eslint": "9.39.4", + "eslint": "9.39.5", "globals": "17.7.0", "ncp": "2.0.0", - "pnpm": "11.9.0", + "pnpm": "11.11.0", "start-server-and-test": "3.0.11", - "typescript": "5.9.3" + "typescript": "npm:@typescript/typescript6@6.0.2" } } diff --git a/packages-private/README.md b/packages-private/README.md new file mode 100644 index 0000000000..0c14dffdf5 --- /dev/null +++ b/packages-private/README.md @@ -0,0 +1,32 @@ +# packages-private + +`packages-private` は、CIなどで使用するためのユーティリティなどを格納する場所です。この配下にあるものは**プロダクションの `/packages`**の依存となるものではありません。 + +## パッケージ一覧 + +| パッケージ | 用途 | +| --- | --- | +| [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ | +| [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する | +| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する | +| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する | + +## GitHub Actionsからの呼び出し方 + +ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。 +CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、 +ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。 + +```yaml +- name: Generate report + working-directory: head + run: >- + pnpm --filter diagnostics-frontend run render-md + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" + "$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" + "$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" + "$REPORT_DIR/report.md" +``` + +base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する** +(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。 diff --git a/scripts/changelog-checker/eslint.config.js b/packages-private/changelog-checker/eslint.config.js similarity index 71% rename from scripts/changelog-checker/eslint.config.js rename to packages-private/changelog-checker/eslint.config.js index 813e96981a..a3b10c3e77 100644 --- a/scripts/changelog-checker/eslint.config.js +++ b/packages-private/changelog-checker/eslint.config.js @@ -1,10 +1,16 @@ import tsParser from '@typescript-eslint/parser'; import sharedConfig from '../../packages/shared/eslint.config.js'; +// eslint-disable-next-line import/no-default-export export default [ ...sharedConfig, { - files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: [ + '**/node_modules', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], languageOptions: { parserOptions: { parser: tsParser, diff --git a/packages-private/changelog-checker/package.json b/packages-private/changelog-checker/package.json new file mode 100644 index 0000000000..30a3af2dd9 --- /dev/null +++ b/packages-private/changelog-checker/package.json @@ -0,0 +1,24 @@ +{ + "name": "changelog-checker", + "private": true, + "type": "module", + "scripts": { + "check": "tsx src/index.ts", + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/mdast": "4.0.4", + "@types/node": "26.1.1", + "@vitest/coverage-v8": "4.1.10", + "mdast-util-to-string": "4.0.0", + "remark": "15.0.1", + "remark-parse": "11.0.0", + "tsx": "4.23.1", + "unified": "11.0.5", + "vitest": "4.1.10" + } +} diff --git a/scripts/changelog-checker/src/checker.ts b/packages-private/changelog-checker/src/checker.ts similarity index 77% rename from scripts/changelog-checker/src/checker.ts rename to packages-private/changelog-checker/src/checker.ts index 8fd6ff5629..01c2598a74 100644 --- a/scripts/changelog-checker/src/checker.ts +++ b/packages-private/changelog-checker/src/checker.ts @@ -33,16 +33,22 @@ export function checkNewRelease(base: Release[], head: Release[]): Result { return Result.ofFailed('Invalid release count.'); } - const baseLatest = base[0]; - const headPrevious = head[releaseCountDiff]; - - if (baseLatest.releaseName !== headPrevious.releaseName) { - return Result.ofFailed('Contains unexpected releases.'); + // 追加分を除いた残り (= base に既にあったリリース) が順序ごと一致することを確認する。 + // 先頭だけ比較していると、より古いリリースが書き換えられていても素通りしてしまう + const existingReleases = head.slice(releaseCountDiff); + for (let relIdx = 0; relIdx < base.length; relIdx++) { + if (base[relIdx].releaseName !== existingReleases[relIdx].releaseName) { + return Result.ofFailed(`Contains unexpected releases. base:${base[relIdx].releaseName}, head:${existingReleases[relIdx].releaseName}`); + } } return Result.ofSuccess(); } +function isSameItems(base: string[], head: string[]) { + return base.length === head.length && base.every((item, idx) => item === head[idx]); +} + /** * topic -> developまたはtopic -> masterを想定したパターン。 * head側の最新リリース配下に書き加えられているかをチェックする。 @@ -78,9 +84,9 @@ export function checkNewTopic(base: Release[], head: Release[]): Result { return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`); } - if (baseCategory.items.length !== headCategory.items.length) { + if (!isSameItems(baseCategory.items, headCategory.items)) { if (headLatest.releaseName !== headItem.releaseName) { - // 最新リリース以外に追記されていた場合 + // 最新リリース以外が変更されていた場合 return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`); } } diff --git a/scripts/changelog-checker/src/index.ts b/packages-private/changelog-checker/src/index.ts similarity index 91% rename from scripts/changelog-checker/src/index.ts rename to packages-private/changelog-checker/src/index.ts index 0e2c5ce057..868ca3f3b6 100644 --- a/scripts/changelog-checker/src/index.ts +++ b/packages-private/changelog-checker/src/index.ts @@ -18,7 +18,7 @@ function abort(message?: string) { function main() { if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) { - console.error('CHANGELOG-base.md or CHANGELOG-head.md is missing.'); + abort('CHANGELOG-base.md or CHANGELOG-head.md is missing.'); return; } diff --git a/scripts/changelog-checker/src/parser.ts b/packages-private/changelog-checker/src/parser.ts similarity index 90% rename from scripts/changelog-checker/src/parser.ts rename to packages-private/changelog-checker/src/parser.ts index 894d60d6af..46b74a4ed2 100644 --- a/scripts/changelog-checker/src/parser.ts +++ b/packages-private/changelog-checker/src/parser.ts @@ -51,6 +51,9 @@ export function parseChangeLog(path: string): Release[] { // リリース release = new Release(toString(it)); releases.push(release); + // 直前のリリースのカテゴリを引き継ぐと、カテゴリ見出しの無いリスト項目が + // 前のリリースに混入するのでリセットする + category = null; } else if (isHeading(it) && it.depth === 3 && release) { // リリース配下のカテゴリ category = new ReleaseCategory(toString(it)); diff --git a/scripts/changelog-checker/test/checker.test.ts b/packages-private/changelog-checker/test/checker.test.ts similarity index 56% rename from scripts/changelog-checker/test/checker.test.ts rename to packages-private/changelog-checker/test/checker.test.ts index 9ca8fa85f2..459379f8db 100644 --- a/scripts/changelog-checker/test/checker.test.ts +++ b/packages-private/changelog-checker/test/checker.test.ts @@ -3,52 +3,62 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import {expect, suite, test} from "vitest"; -import {Release, ReleaseCategory} from "../src/parser"; -import {checkNewRelease, checkNewTopic} from "../src/checker"; +import { expect, describe, test } from 'vitest'; +import { Release, ReleaseCategory } from '../src/parser.js'; +import { checkNewRelease, checkNewTopic } from '../src/checker.js'; -suite('checkNewRelease', () => { +describe('checkNewRelease', () => { test('headに新しいリリースがある1', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.1'), new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.1'), new Release('2024.12.0')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('headに新しいリリースがある2', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')]; - const result = checkNewRelease(base, head) - - expect(result.success).toBe(true) - }) + const result = checkNewRelease(base, head); + expect(result.success).toBe(true); + }); test('リリースの数が同じ', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.0')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('baseにあるリリースがheadにない', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.2'), new Release('2024.12.1')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) -}) + console.log(result.message); + expect(result.success).toBe(false); + }); -suite('checkNewTopic', () => { + // 先頭だけ比較していると、より古いリリースの書き換えを見逃す + test('追加分の直後は一致しているが、より古いリリースが書き換えられている', () => { + const base = [new Release('2024.12.1'), new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.11.0')]; + + const result = checkNewRelease(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); +}); + +describe('checkNewTopic', () => { test('追記なし', () => { const base = [ new Release('2024.12.1', [ @@ -59,7 +69,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -69,9 +79,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -82,7 +92,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -92,14 +102,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンにカテゴリを追加したときはエラーにならない', () => { const base = [ @@ -117,9 +127,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -130,7 +140,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -140,14 +150,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンからカテゴリを削除したときはエラーにならない', () => { const base = [ @@ -159,7 +169,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -169,9 +179,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -188,14 +198,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンに追記したときはエラーにならない', () => { const base = [ @@ -210,8 +220,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -226,13 +236,13 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンから削除したときはエラーにならない', () => { const base = [ @@ -247,8 +257,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -261,13 +271,13 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('古いバージョンにカテゴリを追加したときはエラーになる', () => { const base = [ @@ -282,8 +292,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -301,14 +311,14 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンからカテゴリを削除したときはエラーになる', () => { const base = [ @@ -323,8 +333,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -334,14 +344,14 @@ suite('checkNewTopic', () => { ]), ]), new Release('2024.12.0', [ - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンに追記したときはエラーになる', () => { const base = [ @@ -356,8 +366,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -372,14 +382,14 @@ suite('checkNewTopic', () => { 'feat2', 'feat3', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンから削除したときはエラーになる', () => { const base = [ @@ -394,8 +404,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -408,12 +418,88 @@ suite('checkNewTopic', () => { new ReleaseCategory('Server', [ 'feat1', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) -}) + console.log(result.message); + expect(result.success).toBe(false); + }); + + // 件数が同じでも内容が変わっていれば履歴の書き換えなのでエラーにする + test('古いバージョンの項目が書き換えられたときはエラーになる', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2-rewritten']), + ]), + ]; + + const result = checkNewTopic(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); + + test('古いバージョンの項目が並べ替えられたときはエラーになる', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat2', 'feat1']), + ]), + ]; + + const result = checkNewTopic(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); + + // 最新リリースの書き換えは通常の編集なので許容する + test('最新バージョンの項目を書き換えたときはエラーにならない', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1-rewritten']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1']), + ]), + ]; + + const result = checkNewTopic(base, head); + + expect(result.success).toBe(true); + }); +}); diff --git a/scripts/changelog-checker/tsconfig.json b/packages-private/changelog-checker/tsconfig.json similarity index 89% rename from scripts/changelog-checker/tsconfig.json rename to packages-private/changelog-checker/tsconfig.json index 32f1547eb8..a728e49d4d 100644 --- a/scripts/changelog-checker/tsconfig.json +++ b/packages-private/changelog-checker/tsconfig.json @@ -14,18 +14,17 @@ "experimentalDecorators": true, "noImplicitReturns": true, "esModuleInterop": true, - "typeRoots": [ - "./node_modules/@types" - ], + "skipLibCheck": true, + "types": ["node"], "lib": [ "esnext" ] }, "include": [ - "src/**/*" + "src/**/*", + "test/**/*" ], "exclude": [ "node_modules", - "test/**/*" ] } diff --git a/packages-private/diagnostics-backend/eslint.config.js b/packages-private/diagnostics-backend/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-backend/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-backend/package.json b/packages-private/diagnostics-backend/package.json new file mode 100644 index 0000000000..20fd36e30a --- /dev/null +++ b/packages-private/diagnostics-backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "diagnostics-backend", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "inspect": "tsx src/inspect.ts", + "lint": "pnpm typecheck && pnpm eslint", + "measure": "tsx src/measure-once.ts", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*", + "execa": "9.6.1" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/pg": "8.20.0", + "ioredis": "5.11.1", + "pg": "8.22.0", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/.github/scripts/backend-diagnostics.inspect.mts b/packages-private/diagnostics-backend/src/compare.ts similarity index 51% rename from .github/scripts/backend-diagnostics.inspect.mts rename to packages-private/diagnostics-backend/src/compare.ts index 5bf0f3ee99..8847b44d4a 100644 --- a/.github/scripts/backend-diagnostics.inspect.mts +++ b/packages-private/diagnostics-backend/src/compare.ts @@ -3,75 +3,43 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createRequire } from 'node:module'; import { copyFile, 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 type { MemorySample } from '../../packages/backend/scripts/measure-memory.mts'; +import { execa } from 'execa'; +import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; +import { median } from 'diagnostics-shared/stats'; +import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; +import { resetState } from './db'; +import { measureBackendMemory } from './measure'; +import { memoryPhases, type MemoryReport } from './types'; -const phases = ['afterGc'] as const; +const heapSnapshotLabels = ['base', 'head'] as const; -export type MemoryReport = { - timestamp: string; - sampleCount: number; - aggregation: string; - summary: Record; - heapSnapshot?: heapSnapshotUtil.HeapSnapshotData; - }>; - samples: (MemorySample & { - round: number; - })[]; +type HeapSnapshotLabel = typeof heapSnapshotLabels[number]; + +export type CompareOptions = { + baseDir: string; + headDir: string; + baseOutput: string; + headOutput: string; }; -const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); - -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); -const heapSnapshotLabels = ['base', 'head'] as const; +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); +// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す +const HEAP_SNAPSHOT_OUTPUT_DIR = resolve(readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd()); const HEAP_SNAPSHOT_WORK_DIRS = { - base: resolve('base-heap-snapshots'), - head: resolve('head-heap-snapshots'), + base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'), + head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'), }; const HEAP_SNAPSHOT_OUTPUT_PATHS = { - base: resolve('base-heap-snapshot.heapsnapshot'), - head: resolve('head-heap-snapshot.heapsnapshot'), + base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'), + head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshot.heapsnapshot'), }; -// Use the head checkout's measurement harness for both targets so only the built backend differs. -const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts'); -async function resetState(repoDir: string) { - const require = createRequire(join(repoDir, 'packages/backend/package.json')); - const pg = require('pg'); - const Redis = require('ioredis'); - - const postgres = new pg.Client({ - host: '127.0.0.1', - port: 54312, - database: 'postgres', - user: 'postgres', - }); - - await postgres.connect(); - try { - await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); - await postgres.query('CREATE DATABASE "test-misskey"'); - } finally { - await postgres.end(); - } - - const redis = new Redis({ host: '127.0.0.1', port: 56312 }); - try { - await redis.flushall(); - } finally { - redis.disconnect(); - } -} - -function summarizeSamples(samples: MemoryReport['samples']) { +export function summarizeSamples(samples: MemoryReport['samples']) { const summary = {} as MemoryReport['summary']; - for (const phase of phases) { + for (const phase of memoryPhases) { summary[phase] = { memoryUsage: {}, }; @@ -85,10 +53,10 @@ function summarizeSamples(samples: MemoryReport['samples']) { for (const key of metricKeys) { const values = samples.map(sample => sample.phases[phase].memoryUsage[key]); - summary[phase].memoryUsage[key] = util.median(values); + summary[phase].memoryUsage[key] = median(values); } - const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + const heapSnapshot = summarizeHeapSnapshotDataSamples( samples, sample => sample.phases[phase].heapSnapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }, @@ -101,42 +69,38 @@ function summarizeSamples(samples: MemoryReport['samples']) { async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { process.stderr.write(`[${label}] Resetting database and Redis\n`); - await resetState(repoDir); + await resetState(); process.stderr.write(`[${label}] Running migrations\n`); - await util.run('pnpm', ['--filter', 'backend', 'migrate'], { + // 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる) + await execa('pnpm', ['--filter', 'backend', 'migrate'], { cwd: repoDir, - env: process.env, - logStdout: true, + stdout: ['pipe', process.stderr], + stderr: ['pipe', process.stderr], }); process.stderr.write(`[${label}] Measuring memory\n`); - const measureEnv = { - ...process.env, - MK_MEMORY_BACKEND_DIR: resolve(repoDir, 'packages/backend'), - } as NodeJS.ProcessEnv; - if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0'; - if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath; - - const stdout = await util.run('node', [MEASURE_MEMORY_SCRIPT], { - cwd: repoDir, - env: measureEnv, + return await measureBackendMemory(resolve(repoDir, 'packages/backend'), { + // warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない + ...(round <= 0 ? { heapSnapshot: false } : {}), + heapSnapshotSavePath: options.heapSnapshotSavePath ?? null, }); - - return JSON.parse(stdout) as MemorySample; } -function heapSnapshotPath(label: typeof heapSnapshotLabels[number], round: number) { +function heapSnapshotPath(label: HeapSnapshotLabel, round: number) { return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`); } +/** + * 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。 + */ function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { - const medianTotal = summary.afterGc.heapSnapshot?.categories?.total; + const medianTotal = summary.afterGc.heapSnapshot?.categories.total; if (medianTotal == null || !Number.isFinite(medianTotal)) return null; let selected: { round: number; distance: number } | null = null; for (const sample of samples) { - const total = sample.phases.afterGc.heapSnapshot?.categories?.total; + const total = sample.phases.afterGc.heapSnapshot?.categories.total; if (total == null || !Number.isFinite(total)) continue; const distance = Math.abs(total - medianTotal); @@ -151,7 +115,7 @@ function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], return selected?.round ?? null; } -async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) { +async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) { const round = selectRepresentativeHeapSnapshotRound(samples, summary); if (round == null) return; @@ -160,13 +124,13 @@ async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[n await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true }); } -async function main() { - const baseDir = resolve(baseDirArg); - const headDir = resolve(headDirArg); - const baseOutput = resolve(baseOutputArg); - const headOutput = resolve(headOutputArg); - const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1); - const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); +/** + * base / head を交互に計測してJSONレポートを書き出す。 + * 交互にするのは、実行順やマシンの状態による偏りを両者に均等に載せるため。 + */ +export async function compareBackendMemory(options: CompareOptions) { + const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1); + const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); const startedAt = new Date().toISOString(); for (const label of heapSnapshotLabels) { @@ -176,11 +140,11 @@ async function main() { const reports = { base: { - dir: baseDir, + dir: options.baseDir, samples: [] as MemoryReport['samples'], }, head: { - dir: headDir, + dir: options.headDir, samples: [] as MemoryReport['samples'], }, }; @@ -197,8 +161,7 @@ async function main() { process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`); for (const label of order) { - const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) }; - const sample = await genSample(label, reports[label].dir, round, options); + const sample = await genSample(label, reports[label].dir, round, { heapSnapshotSavePath: heapSnapshotPath(label, round) }); reports[label].samples.push({ ...sample, round, @@ -215,7 +178,7 @@ async function main() { } for (const label of heapSnapshotLabels) { - const report = { + const report: MemoryReport = { timestamp: new Date().toISOString(), sampleCount: reports[label].samples.length, aggregation: 'median', @@ -229,11 +192,6 @@ async function main() { samples: reports[label].samples, }; - await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`); } } - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-backend/src/db.ts b/packages-private/diagnostics-backend/src/db.ts new file mode 100644 index 0000000000..13045b6b90 --- /dev/null +++ b/packages-private/diagnostics-backend/src/db.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import Redis from 'ioredis'; +import pg from 'pg'; + +/** + * 計測ラウンド間で状態を持ち越さないよう、テスト用DBを作り直しRedisを空にする。 + * 接続先はCIのテスト用サービス (.github/misskey/test.yml) と揃えてある。 + */ +export async function resetState() { + const postgres = new pg.Client({ + host: '127.0.0.1', + port: 54312, + database: 'postgres', + user: 'postgres', + }); + + await postgres.connect(); + try { + await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); + await postgres.query('CREATE DATABASE "test-misskey"'); + } finally { + await postgres.end(); + } + + const redis = new Redis({ host: '127.0.0.1', port: 56312 }); + try { + await redis.flushall(); + } finally { + redis.disconnect(); + } +} diff --git a/packages-private/diagnostics-backend/src/inspect.ts b/packages-private/diagnostics-backend/src/inspect.ts new file mode 100644 index 0000000000..c98ea6b644 --- /dev/null +++ b/packages-private/diagnostics-backend/src/inspect.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { resolve } from 'node:path'; +import { compareBackendMemory } from './compare'; + +const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { + console.error('Usage: inspect '); + process.exit(1); +} + +try { + await compareBackendMemory({ + baseDir: resolve(baseDirArg), + headDir: resolve(headDirArg), + baseOutput: resolve(baseOutputArg), + headOutput: resolve(headOutputArg), + }); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/packages-private/diagnostics-backend/src/measure-once.ts b/packages-private/diagnostics-backend/src/measure-once.ts new file mode 100644 index 0000000000..6efdbf09d0 --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure-once.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { resolve } from 'node:path'; +import { readOptionalEnv } from 'diagnostics-shared/env'; +import { measureBackendMemory } from './measure'; + +// ローカルデバッグ用: バックエンド1回分の計測結果をJSONで出力する +const [backendDirArg] = process.argv.slice(2); + +if (backendDirArg == null) { + console.error('Usage: measure '); + process.exit(1); +} + +try { + const sample = await measureBackendMemory(resolve(backendDirArg), { + heapSnapshotSavePath: readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH'), + }); + console.log(JSON.stringify(sample, null, 2)); +} catch (err) { + console.error(JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + timestamp: new Date().toISOString(), + })); + process.exit(1); +} diff --git a/packages-private/diagnostics-backend/src/measure/index.ts b/packages-private/diagnostics-backend/src/measure/index.ts new file mode 100644 index 0000000000..3acf1b0e66 --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/index.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { readBooleanEnv, readIntegerEnv } from 'diagnostics-shared/env'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; +import { getMemoryUsage, getSmapsRollupMemoryUsage } from './proc'; +import { + forkBackendServer, + getRuntimeMemoryUsage, + requestHeapSnapshot, + shutdownBackendServer, + triggerGc, + waitForServerReady, +} from './server'; +import { measureMemoryUntilStable } from './stability'; +import type { MemorySample } from '../types'; + +export type MeasureBackendMemoryOptions = { + /** heap snapshotを取得するか (既定: MK_MEMORY_HEAP_SNAPSHOT) */ + heapSnapshot?: boolean; + /** 取得したheap snapshotの保存先。未指定なら解析後に破棄する */ + heapSnapshotSavePath?: string | null; + heapSnapshotBreakdownTopN?: number; + heapSnapshotTimeoutMs?: number; + startupTimeoutMs?: number; + ipcTimeoutMs?: number; +}; + +function resolveOptions(options: MeasureBackendMemoryOptions) { + return { + heapSnapshot: options.heapSnapshot ?? readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false), + heapSnapshotSavePath: options.heapSnapshotSavePath ?? null, + heapSnapshotBreakdownTopN: options.heapSnapshotBreakdownTopN ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1), + heapSnapshotTimeoutMs: options.heapSnapshotTimeoutMs ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1), + startupTimeoutMs: options.startupTimeoutMs ?? readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1), + ipcTimeoutMs: options.ipcTimeoutMs ?? readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1), + }; +} + +/** + * バックエンドを1回起動し、GC後のメモリ使用量を計測して1サンプル分の結果を返す。 + */ +export async function measureBackendMemory(backendDir: string, options: MeasureBackendMemoryOptions = {}): Promise { + const settings = resolveOptions(options); + const serverProcess = forkBackendServer(backendDir); + + // 起動完了メッセージを取りこぼさないよう、他のハンドラより先に待ち受ける + const serverReady = waitForServerReady(serverProcess, settings.startupTimeoutMs); + + serverProcess.stdout?.on('data', (data) => { + process.stderr.write(`[server stdout] ${data}`); + }); + + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(`[server stderr] ${data}`); + }); + + serverProcess.on('error', (err) => { + process.stderr.write(`[server error] ${err}\n`); + }); + + // 途中で失敗しても子プロセスを残さない。残すと次のラウンドがポート衝突で落ちる + try { + const startupStartTime = Date.now(); + await serverReady; + + const startupTime = Date.now() - startupStartTime; + process.stderr.write(`Server started in ${startupTime}ms\n`); + + await triggerGc(serverProcess, settings.ipcTimeoutMs); + + const pid = serverProcess.pid!; + const stableSmapsRollup = await measureMemoryUntilStable(() => getSmapsRollupMemoryUsage(pid)); + const afterGc = { + memoryUsage: { + ...await getMemoryUsage(pid), + ...stableSmapsRollup.memoryUsage, + ...await getRuntimeMemoryUsage(serverProcess, settings.ipcTimeoutMs), + }, + stability: stableSmapsRollup.stability, + }; + process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`); + + const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess, settings); + + return { + timestamp: new Date().toISOString(), + phases: { + afterGc: { + memoryUsage: afterGc.memoryUsage, + memoryStability: afterGc.stability, + heapSnapshot: heapSnapshotAfterGc, + }, + }, + }; + } finally { + await shutdownBackendServer(serverProcess); + } +} + +async function getHeapSnapshotStatistics( + serverProcess: ReturnType, + settings: ReturnType, +): Promise { + if (!settings.heapSnapshot) return null; + + const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); + const writtenPath = await requestHeapSnapshot(serverProcess, snapshotPath, settings.heapSnapshotTimeoutMs); + + try { + if (settings.heapSnapshotSavePath != null && settings.heapSnapshotSavePath !== '') { + await fs.mkdir(dirname(settings.heapSnapshotSavePath), { recursive: true }); + await fs.copyFile(writtenPath, settings.heapSnapshotSavePath); + } + + const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); + return analyzeHeapSnapshot(snapshot, { breakdownTopN: settings.heapSnapshotBreakdownTopN }); + } finally { + // 数百MBになることがあるため、解析後は必ず消す + await fs.unlink(writtenPath).catch(err => { + process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); + }); + } +} diff --git a/packages-private/diagnostics-backend/src/measure/proc.ts b/packages-private/diagnostics-backend/src/measure/proc.ts new file mode 100644 index 0000000000..435c3ac54c --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/proc.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs/promises'; + +export const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; +export const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; + +/** + * `/proc` 配下の `Key: 1234 kB` 形式のファイルから指定キーを取り出す。 + * 1つでも欠けていると以降の集計が静かに壊れるため、見つからなければ例外にする。 + */ +export function parseMemoryFile(content: string, keys: KS, path: string): Record { + const result = {} as Record; + for (const _key of keys) { + const key = _key as KS[number]; + const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); + if (match) { + result[key] = parseInt(match[1], 10); + } else { + throw new Error(`Failed to parse ${key} from ${path}`); + } + } + return result; +} + +export function bytesToKiB(value: number) { + return Math.round(value / 1024); +} + +export async function getMemoryUsage(pid: number) { + const path = `/proc/${pid}/status`; + const status = await fs.readFile(path, 'utf-8'); + return parseMemoryFile(status, procStatusKeys, path); +} + +export async function getSmapsRollupMemoryUsage(pid: number) { + const path = `/proc/${pid}/smaps_rollup`; + const smapsRollup = await fs.readFile(path, 'utf-8'); + return parseMemoryFile(smapsRollup, smapsRollupKeys, path); +} diff --git a/packages-private/diagnostics-backend/src/measure/server.ts b/packages-private/diagnostics-backend/src/measure/server.ts new file mode 100644 index 0000000000..ee3d74ba9f --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/server.ts @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { fork, type ChildProcess } from 'node:child_process'; +import { join } from 'node:path'; +import { bytesToKiB } from './proc'; + +type GcMessage = 'gc ok' | 'gc unavailable'; +type RuntimeMemoryUsageMessage = { + type: 'memory usage'; + value: NodeJS.MemoryUsage; +}; +type HeapSnapshotMessage = { + type: 'heap snapshot'; + path?: string; +}; +type HeapSnapshotErrorMessage = { + type: 'heap snapshot error'; + message: string; +}; +type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object'; +} + +function isGcMessage(message: unknown): message is GcMessage { + return message === 'gc ok' || message === 'gc unavailable'; +} + +function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { + return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); +} + +function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { + if (!isRecord(message)) return false; + if (message.type === 'heap snapshot') return true; + return message.type === 'heap snapshot error' && typeof message.message === 'string'; +} + +export function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) { + return new Promise((resolve, reject) => { + const cleanup = () => { + globalThis.clearTimeout(timer); + serverProcess.off('message', onMessage); + serverProcess.off('exit', onExit); + serverProcess.off('error', onError); + serverProcess.off('disconnect', onDisconnect); + }; + + const timer = globalThis.setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeout); + + const onMessage = (message: unknown) => { + if (!predicate(message)) return; + cleanup(); + resolve(message); + }; + + // 子が死んだ場合、待ち続けてもメッセージは来ない。 + // タイムアウトまで待って誤解を招くエラーを出すより、理由を添えて即座に失敗させる + const onExit = (code: number | null, signal: string | null) => { + cleanup(); + reject(new Error(`Server exited (code=${code}, signal=${signal}) while waiting for ${description}`)); + }; + + const onError = (err: Error) => { + cleanup(); + reject(new Error(`Server errored while waiting for ${description}: ${err.message}`)); + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error(`Server IPC channel closed while waiting for ${description}`)); + }; + + serverProcess.on('message', onMessage); + serverProcess.once('exit', onExit); + serverProcess.once('error', onError); + serverProcess.once('disconnect', onDisconnect); + }); +} + +/** + * ビルド済みバックエンドを子プロセスとして起動する。 + * execArgv は親から引き継がず `--expose-gc` のみを渡す: 親は tsx 経由で動くため、 + * 引き継ぐと計測対象プロセスにTSローダーが載ってしまいメモリ量が歪む。 + */ +export function forkBackendServer(backendDir: string) { + return fork(join(backendDir, 'built/entry.js'), [], { + cwd: backendDir, + env: { + ...process.env, + NODE_ENV: 'production', + MK_DISABLE_CLUSTERING: '1', + MK_ONLY_SERVER: '1', + MK_NO_DAEMONS: '1', + }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + execArgv: ['--expose-gc'], + }); +} + +export function waitForServerReady(serverProcess: ChildProcess, timeout: number) { + return waitForMessage( + serverProcess, + (message): message is 'ok' => message === 'ok', + 'server startup', + timeout, + ); +} + +export async function triggerGc(serverProcess: ChildProcess, timeout: number) { + // 送信前にlistenerを張らないと、応答を取りこぼす可能性がある + const ok = waitForMessage(serverProcess, isGcMessage, 'GC completion', timeout); + + serverProcess.send('gc'); + + const message = await ok; + if (message === 'gc unavailable') { + throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); + } +} + +export async function getRuntimeMemoryUsage(serverProcess: ChildProcess, timeout: number) { + const response = waitForMessage( + serverProcess, + isRuntimeMemoryUsageMessage, + 'memory usage', + timeout, + ); + + serverProcess.send('memory usage'); + + const message = await response; + const memoryUsage = message.value; + + // /proc 由来の値と単位を揃える + return { + HeapTotal: bytesToKiB(memoryUsage.heapTotal), + HeapUsed: bytesToKiB(memoryUsage.heapUsed), + External: bytesToKiB(memoryUsage.external), + ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), + }; +} + +/** + * heap snapshotの書き出しを依頼し、実際に書かれたパスを返す。 + */ +export async function requestHeapSnapshot(serverProcess: ChildProcess, snapshotPath: string, timeout: number) { + const response = waitForMessage( + serverProcess, + isHeapSnapshotResponseMessage, + 'heap snapshot', + timeout, + ); + + serverProcess.send({ + type: 'heap snapshot', + path: snapshotPath, + }); + + const message = await response; + if (message.type === 'heap snapshot error') { + throw new Error(`Failed to write heap snapshot: ${message.message}`); + } + + return typeof message.path === 'string' ? message.path : snapshotPath; +} + +/** + * SIGTERMで終了を促し、一定時間で落ちなければSIGKILLする。 + */ +export async function shutdownBackendServer(serverProcess: ChildProcess) { + // 既に終了しているなら 'exit' はもう発火しないので、待つと無駄に10秒止まる + if (serverProcess.exitCode != null || serverProcess.signalCode != null) return; + + await new Promise(resolve => { + let forceTimer: NodeJS.Timeout | undefined; + const termTimer = globalThis.setTimeout(() => { + serverProcess.kill('SIGKILL'); + // SIGKILLは無視できないので通常はここで 'exit' が来る。 + // D状態などで落ちない場合に計測全体を止めないよう、待ち時間には上限を設ける + forceTimer = globalThis.setTimeout(resolve, 5000); + }, 10000); + + serverProcess.once('exit', () => { + globalThis.clearTimeout(termTimer); + if (forceTimer != null) globalThis.clearTimeout(forceTimer); + resolve(); + }); + + serverProcess.kill('SIGTERM'); + }); +} diff --git a/.github/scripts/memory-stability-util.mts b/packages-private/diagnostics-backend/src/measure/stability.ts similarity index 91% rename from .github/scripts/memory-stability-util.mts rename to packages-private/diagnostics-backend/src/measure/stability.ts index 0a9a8d4f52..0c390088c2 100644 --- a/.github/scripts/memory-stability-util.mts +++ b/packages-private/diagnostics-backend/src/measure/stability.ts @@ -38,6 +38,10 @@ function getMaxAbsoluteSlopes>(readings: { elap return result; } +/** + * メモリ使用量が落ち着くまで繰り返し読み取る。 + * 起動直後は遅延初期化でじわじわ増え続けるため、直近 `windowSize` 件の傾きが十分小さくなるまで待つ。 + */ export async function measureMemoryUntilStable>( readMemoryUsage: () => Promise, timer: MemoryStabilityTimer = defaultTimer, diff --git a/packages-private/diagnostics-backend/src/render-md.ts b/packages-private/diagnostics-backend/src/render-md.ts new file mode 100644 index 0000000000..991ac0fe53 --- /dev/null +++ b/packages-private/diagnostics-backend/src/render-md.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readRequiredEnv } from 'diagnostics-shared/env'; +import { renderMemoryReportMarkdown } from './report/markdown'; +import type { MemoryReport } from './types'; + +async function main() { + const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); + if (baseFileArg == null || headFileArg == null || outputFileArg == null) { + throw new Error('Usage: render-md '); + } + + const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as MemoryReport; + const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as MemoryReport; + + await writeFile(resolve(outputFileArg), renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE'), + headHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD'), + })); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-backend/src/report/markdown.ts b/packages-private/diagnostics-backend/src/report/markdown.ts new file mode 100644 index 0000000000..bd4655dbe2 --- /dev/null +++ b/packages-private/diagnostics-backend/src/report/markdown.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format'; +import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; +import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot'; +import type { MemoryPhase, MemoryReport } from '../types'; + +export type RenderMemoryReportOptions = { + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; +}; + +const memoryReportPhases = [ + { + key: 'afterGc', + title: 'After GC', + }, +] as const satisfies readonly { key: MemoryPhase; title: string }[]; + +const memoryMetrics = [ + 'HeapUsed', + 'Pss', + 'USS', + 'External', +] as const; + +type MemoryMetric = typeof memoryMetrics[number]; + +function formatMemoryMetricName(metric: MemoryMetric) { + return metric === 'Pss' ? 'PSS' : metric; +} + +function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) { + const memoryUsage = sample.phases[phase].memoryUsage; + // USSは直接取れないのでPrivateの合算で近似する + if (metric !== 'USS') return memoryUsage[metric]; + return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; +} + +function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); +} + +function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; + return median(getSampleValues(report, phase, metric)); +} + +function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + return sampleSpread(getSampleValues(report, phase, metric)); +} + +function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + + function formatDeltaMemory(deltaKiB: number) { + return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold + } + + for (const metric of memoryMetrics) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + const summary = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaMemory(summary.median)}
${formatDeltaPercentInMdTable(percent, 0.1)}`; + + lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)}
± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)}
± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); + } + + return lines.join('\n'); +} + +function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { + const baseHeapSnapshotReport = { + summary: base.summary.afterGc.heapSnapshot!, + samples: base.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const headHeapSnapshotReport = { + summary: head.summary.afterGc.heapSnapshot!, + samples: head.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); + if (table == null) return null; + + const lines = [ + '### V8 Heap Snapshot Statistics', + '', + table, + '', + ]; + + // Sankeyはノイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する + for (const graph of [ + //renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), + //renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), + ] as (string | null)[]) { + if (graph == null) continue; + lines.push(graph); + lines.push(''); + } + + return lines.join('\n'); +} + +function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + return ((headValue - baseValue) * 100) / baseValue; +} + +/** + * 増加分がサンプルのばらつきを明確に超えているかを見る。 + * 測定ノイズで警告が出続けるのを避けるため、合成ばらつきの3倍を閾値にする。 + */ +function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const delta = headValue - baseValue; + if (delta <= 0) return false; + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + if (baseSpread == null || headSpread == null) return true; + + const combinedSpread = Math.hypot(baseSpread, headSpread); + return delta > combinedSpread * 3; +} + +export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) { + const lines = [ + '## ⚙️ Backend Diagnostics Report', + '', + ]; + + //const summary = measurementSummary(base, head); + //if (summary != null) { + // lines.push(summary); + // lines.push(''); + //} + + for (const phase of memoryReportPhases) { + lines.push(`### Memory: ${phase.title}`); + lines.push(renderMainTableForPhase(base, head, phase.key)); + lines.push(''); + } + + const heapSnapshotSection = renderHeapSnapshotSection(base, head); + if (heapSnapshotSection != null) { + lines.push(heapSnapshotSection); + lines.push(''); + } + + lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`); + lines.push(''); + + const warningMetric = 'Pss'; + const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); + if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { + lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); + lines.push(''); + } + + return `${lines.join('\n')}\n`; +} diff --git a/packages-private/diagnostics-backend/src/types.ts b/packages-private/diagnostics-backend/src/types.ts new file mode 100644 index 0000000000..2dff718a5c --- /dev/null +++ b/packages-private/diagnostics-backend/src/types.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; + +/** 計測フェーズ。将来的に増やせるようリスト化してある */ +export const memoryPhases = ['afterGc'] as const; + +export type MemoryPhase = typeof memoryPhases[number]; + +export type MemoryStability = { + converged: boolean; + readingCount: number; + elapsedMs: number; + maxAbsoluteSlopesKiBPerSecond: Record | null; +}; + +/** バックエンドを1回起動して得られる計測結果 */ +export type MemorySample = { + timestamp: string; + phases: Record; + memoryStability: MemoryStability; + heapSnapshot: HeapSnapshotData | null; + }>; +}; + +/** base / head それぞれについて出力されるJSONレポート */ +export type MemoryReport = { + timestamp: string; + sampleCount: number; + aggregation: string; + comparison?: { + strategy: string; + rounds: number; + warmupRounds: number; + startedAt: string; + }; + summary: Record; + heapSnapshot?: HeapSnapshotData; + }>; + samples: (MemorySample & { + round: number; + })[]; +}; diff --git a/packages-private/diagnostics-backend/test/__snapshots__/render-md.md b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md new file mode 100644 index 0000000000..f2b05b3918 --- /dev/null +++ b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md @@ -0,0 +1,29 @@ +## ⚙️ Backend Diagnostics Report + +### Memory: After GC +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **HeapUsed** | 152 MB
± 1 MB | 168 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+10.5\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **PSS** | 202 MB
± 1 MB | 218 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+7.9\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **USS** | 184 MB
± 1 MB | 200 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+8.7\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **External** | 8.2 MB
± 0.1 MB | 8.2 MB
± 0.1 MB | 0 MB
0% | 0 MB | 0 MB | 0 MB | + +### V8 Heap Snapshot Statistics + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB
± 200 KB | 44 MB
± 200 KB | $\color{orange}{\text{+3.2 MB}}$
$\color{orange}{\text{+7.9\\%}}$ | 0 B | $\color{orange}{\text{+3.2 MB}}$ | $\color{orange}{\text{+3.2 MB}}$ | +| | | | | | | | +|
$\color{orange}{\rule{8pt}{8pt}}$ **Code**11.8% → 11.8%
| 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 0 B | $\color{orange}{\text{+376 KB}}$ | $\color{orange}{\text{+376 KB}}$ | +|
$\color{red}{\rule{8pt}{8pt}}$ **Strings**11% → 11%
| 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 0 B | $\color{orange}{\text{+352 KB}}$ | $\color{orange}{\text{+352 KB}}$ | +|
$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**10.3% → 10.3%
| 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 0 B | $\color{orange}{\text{+328 KB}}$ | $\color{orange}{\text{+328 KB}}$ | +|
$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**9.5% → 9.5%
| 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 0 B | $\color{orange}{\text{+304 KB}}$ | $\color{orange}{\text{+304 KB}}$ | +|
$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**8.8% → 8.8%
| 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 0 B | $\color{orange}{\text{+280 KB}}$ | $\color{orange}{\text{+280 KB}}$ | +|
$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**8% → 8%
| 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 0 B | $\color{orange}{\text{+256 KB}}$ | $\color{orange}{\text{+256 KB}}$ | +|
$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**7.3% → 7.3%
| 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 0 B | $\color{orange}{\text{+232 KB}}$ | $\color{orange}{\text{+232 KB}}$ | + + +Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) + +⚠️ **Warning**: Memory usage (PSS) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change. + diff --git a/packages-private/diagnostics-backend/test/fixtures/base.json b/packages-private/diagnostics-backend/test/fixtures/base.json new file mode 100644 index 0000000000..d9022b9b3c --- /dev/null +++ b/packages-private/diagnostics-backend/test/fixtures/base.json @@ -0,0 +1,200 @@ +{ + "timestamp": "2026-07-18T07:57:17.653Z", + "sampleCount": 3, + "aggregation": "median", + "comparison": { + "strategy": "interleaved-pairs", + "rounds": 3, + "warmupRounds": 1, + "startedAt": "2026-07-18T07:57:17.653Z" + }, + "summary": { + "afterGc": { + "memoryUsage": { + "VmRSS": 202000, + "Pss": 202000, + "Private_Clean": 2020, + "Private_Dirty": 182000, + "HeapUsed": 152000, + "HeapTotal": 170000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "heapSnapshot": { + "categories": { + "total": 40400000, + "code": 4747000, + "strings": 4444000, + "jsArrays": 4141000, + "typedArrays": 3838000, + "systemObjects": 3535000, + "otherJsObjects": 3232000, + "otherNonJsObjects": 2929000 + }, + "nodeCounts": { + "total": 404000, + "code": 47470, + "strings": 44440, + "jsArrays": 41410, + "typedArrays": 38380, + "systemObjects": 35350, + "otherJsObjects": 32320, + "otherNonJsObjects": 29290 + }, + "breakdowns": {} + } + } + }, + "samples": [ + { + "round": 1, + "timestamp": "2026-07-18T07:57:17.652Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 201000, + "Pss": 201000, + "Private_Clean": 2010, + "Private_Dirty": 181000, + "HeapUsed": 151000, + "HeapTotal": 170000, + "External": 8100, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40200000, + "code": 4723500, + "strings": 4422000, + "jsArrays": 4120500, + "typedArrays": 3819000, + "systemObjects": 3517500, + "otherJsObjects": 3216000, + "otherNonJsObjects": 2914500 + }, + "nodeCounts": { + "total": 402000, + "code": 47235, + "strings": 44220, + "jsArrays": 41205, + "typedArrays": 38190, + "systemObjects": 35175, + "otherJsObjects": 32160, + "otherNonJsObjects": 29145 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 2, + "timestamp": "2026-07-18T07:57:17.653Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 202000, + "Pss": 202000, + "Private_Clean": 2020, + "Private_Dirty": 182000, + "HeapUsed": 152000, + "HeapTotal": 170000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40400000, + "code": 4747000, + "strings": 4444000, + "jsArrays": 4141000, + "typedArrays": 3838000, + "systemObjects": 3535000, + "otherJsObjects": 3232000, + "otherNonJsObjects": 2929000 + }, + "nodeCounts": { + "total": 404000, + "code": 47470, + "strings": 44440, + "jsArrays": 41410, + "typedArrays": 38380, + "systemObjects": 35350, + "otherJsObjects": 32320, + "otherNonJsObjects": 29290 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 3, + "timestamp": "2026-07-18T07:57:17.653Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 203000, + "Pss": 203000, + "Private_Clean": 2030, + "Private_Dirty": 183000, + "HeapUsed": 153000, + "HeapTotal": 170000, + "External": 8300, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40600000, + "code": 4770500, + "strings": 4466000, + "jsArrays": 4161500, + "typedArrays": 3857000, + "systemObjects": 3552500, + "otherJsObjects": 3248000, + "otherNonJsObjects": 2943500 + }, + "nodeCounts": { + "total": 406000, + "code": 47705, + "strings": 44660, + "jsArrays": 41615, + "typedArrays": 38570, + "systemObjects": 35525, + "otherJsObjects": 32480, + "otherNonJsObjects": 29435 + }, + "breakdowns": {} + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-backend/test/fixtures/head.json b/packages-private/diagnostics-backend/test/fixtures/head.json new file mode 100644 index 0000000000..3f9040ec93 --- /dev/null +++ b/packages-private/diagnostics-backend/test/fixtures/head.json @@ -0,0 +1,200 @@ +{ + "timestamp": "2026-07-18T07:57:17.654Z", + "sampleCount": 3, + "aggregation": "median", + "comparison": { + "strategy": "interleaved-pairs", + "rounds": 3, + "warmupRounds": 1, + "startedAt": "2026-07-18T07:57:17.654Z" + }, + "summary": { + "afterGc": { + "memoryUsage": { + "VmRSS": 218000, + "Pss": 218000, + "Private_Clean": 2020, + "Private_Dirty": 198000, + "HeapUsed": 168000, + "HeapTotal": 186000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "heapSnapshot": { + "categories": { + "total": 43600000, + "code": 5123000, + "strings": 4796000, + "jsArrays": 4469000, + "typedArrays": 4142000, + "systemObjects": 3815000, + "otherJsObjects": 3488000, + "otherNonJsObjects": 3161000 + }, + "nodeCounts": { + "total": 436000, + "code": 51230, + "strings": 47960, + "jsArrays": 44690, + "typedArrays": 41420, + "systemObjects": 38150, + "otherJsObjects": 34880, + "otherNonJsObjects": 31610 + }, + "breakdowns": {} + } + } + }, + "samples": [ + { + "round": 1, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 217000, + "Pss": 217000, + "Private_Clean": 2010, + "Private_Dirty": 197000, + "HeapUsed": 167000, + "HeapTotal": 186000, + "External": 8100, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43400000, + "code": 5099500, + "strings": 4774000, + "jsArrays": 4448500, + "typedArrays": 4123000, + "systemObjects": 3797500, + "otherJsObjects": 3472000, + "otherNonJsObjects": 3146500 + }, + "nodeCounts": { + "total": 434000, + "code": 50995, + "strings": 47740, + "jsArrays": 44485, + "typedArrays": 41230, + "systemObjects": 37975, + "otherJsObjects": 34720, + "otherNonJsObjects": 31465 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 2, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 218000, + "Pss": 218000, + "Private_Clean": 2020, + "Private_Dirty": 198000, + "HeapUsed": 168000, + "HeapTotal": 186000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43600000, + "code": 5123000, + "strings": 4796000, + "jsArrays": 4469000, + "typedArrays": 4142000, + "systemObjects": 3815000, + "otherJsObjects": 3488000, + "otherNonJsObjects": 3161000 + }, + "nodeCounts": { + "total": 436000, + "code": 51230, + "strings": 47960, + "jsArrays": 44690, + "typedArrays": 41420, + "systemObjects": 38150, + "otherJsObjects": 34880, + "otherNonJsObjects": 31610 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 3, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 219000, + "Pss": 219000, + "Private_Clean": 2030, + "Private_Dirty": 199000, + "HeapUsed": 169000, + "HeapTotal": 186000, + "External": 8300, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43800000, + "code": 5146500, + "strings": 4818000, + "jsArrays": 4489500, + "typedArrays": 4161000, + "systemObjects": 3832500, + "otherJsObjects": 3504000, + "otherNonJsObjects": 3175500 + }, + "nodeCounts": { + "total": 438000, + "code": 51465, + "strings": 48180, + "jsArrays": 44895, + "typedArrays": 41610, + "systemObjects": 38325, + "otherJsObjects": 35040, + "otherNonJsObjects": 31755 + }, + "breakdowns": {} + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-backend/test/render-md.test.ts b/packages-private/diagnostics-backend/test/render-md.test.ts new file mode 100644 index 0000000000..468822014f --- /dev/null +++ b/packages-private/diagnostics-backend/test/render-md.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect, test } from 'vitest'; +import { renderMemoryReportMarkdown } from '../src/report/markdown'; +import type { MemoryReport } from '../src/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport; +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders the backend memory report', async () => { + const markdown = renderMemoryReportMarkdown(await loadFixture('base'), await loadFixture('head'), { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + }); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); +}); diff --git a/packages-private/diagnostics-backend/test/server.test.ts b/packages-private/diagnostics-backend/test/server.test.ts new file mode 100644 index 0000000000..b99c6f9151 --- /dev/null +++ b/packages-private/diagnostics-backend/test/server.test.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { describe, expect, test } from 'vitest'; +import { waitForMessage } from '../src/measure/server'; + +/** waitForMessage が使うのは message/exit/error/disconnect の購読だけなので EventEmitter で足りる */ +function createFakeServer() { + return new EventEmitter() as unknown as ChildProcess; +} + +function isPing(message: unknown): message is 'ping' { + return message === 'ping'; +} + +describe('waitForMessage', () => { + test('resolves with the first matching message', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 1_000); + + server.emit('message', 'noise'); + server.emit('message', 'ping'); + + await expect(received).resolves.toBe('ping'); + }); + + // 子が死んだあとメッセージは来ないので、タイムアウトまで待たずに理由を添えて失敗させる + test('rejects immediately when the server exits', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('exit', 1, null); + + await expect(received).rejects.toThrow(/Server exited \(code=1, signal=null\) while waiting for ping/); + }); + + test('rejects immediately when the server errors', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('error', new Error('spawn failed')); + + await expect(received).rejects.toThrow(/spawn failed/); + }); + + test('rejects immediately when the IPC channel closes', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('disconnect'); + + await expect(received).rejects.toThrow(/IPC channel closed/); + }); + + test('rejects on timeout', async () => { + const server = createFakeServer(); + await expect(waitForMessage(server, isPing, 'ping', 1)).rejects.toThrow(/Timed out waiting for ping/); + }); + + // 待機が終わった後もリスナーが残っていると、ラウンドを重ねるごとに積み上がる + test('removes every listener once settled', async () => { + const server = createFakeServer(); + const emitter = server as unknown as EventEmitter; + const received = waitForMessage(server, isPing, 'ping', 1_000); + + server.emit('message', 'ping'); + await received; + + for (const event of ['message', 'exit', 'error', 'disconnect']) { + expect(emitter.listenerCount(event)).toBe(0); + } + }); +}); diff --git a/.github/scripts/memory-stability-util.test.mts b/packages-private/diagnostics-backend/test/stability.test.ts similarity index 77% rename from .github/scripts/memory-stability-util.test.mts rename to packages-private/diagnostics-backend/test/stability.test.ts index 70bbb3924b..e27332b279 100644 --- a/.github/scripts/memory-stability-util.test.mts +++ b/packages-private/diagnostics-backend/test/stability.test.ts @@ -3,9 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { measureMemoryUntilStable } from './memory-stability-util.mts'; +import { expect, test } from 'vitest'; +import { measureMemoryUntilStable } from '../src/measure/stability'; function createTimer() { let elapsedMs = 0; @@ -35,9 +34,9 @@ test('adopts the latest reading once Pss and Private_Dirty slopes converge', asy ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 3); - assert.deepEqual(result.memoryUsage, readings[2]); - assert.deepEqual(result.stability, { + expect(readCount).toBe(3); + expect(result.memoryUsage).toStrictEqual(readings[2]); + expect(result.stability).toStrictEqual({ converged: true, readingCount: 3, elapsedMs: 4000, @@ -58,9 +57,9 @@ test('uses only the latest readings when determining convergence', async () => { ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 5); - assert.equal(result.stability.converged, true); - assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + expect(readCount).toBe(5); + expect(result.stability.converged).toBe(true); + expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({ Pss: 20, Private_Dirty: 10, }); @@ -77,9 +76,9 @@ test('bounds the wait and reports the latest slopes when memory does not converg ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 6); - assert.deepEqual(result.memoryUsage, readings[5]); - assert.deepEqual(result.stability, { + expect(readCount).toBe(6); + expect(result.memoryUsage).toStrictEqual(readings[5]); + expect(result.stability).toStrictEqual({ converged: false, readingCount: 6, elapsedMs: 10000, @@ -101,9 +100,9 @@ test('does not treat opposing adjacent slopes as convergence', async () => { ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 6); - assert.equal(result.stability.converged, false); - assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + expect(readCount).toBe(6); + expect(result.stability.converged).toBe(false); + expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({ Pss: 300, Private_Dirty: 0, }); diff --git a/packages-private/diagnostics-backend/tsconfig.json b/packages-private/diagnostics-backend/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-frontend/eslint.config.js b/packages-private/diagnostics-frontend/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-frontend/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-frontend/package.json b/packages-private/diagnostics-frontend/package.json new file mode 100644 index 0000000000..8d3d2d248c --- /dev/null +++ b/packages-private/diagnostics-frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "diagnostics-frontend", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "inspect-browser": "tsx src/inspect-browser.ts", + "lint": "pnpm typecheck && pnpm eslint", + "render-browser-html": "tsx src/render-browser-html.ts", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "26.1.1", + "frontend": "workspace:*", + "playwright": "1.61.1", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-frontend/src/browser/controller.ts b/packages-private/diagnostics-frontend/src/browser/controller.ts new file mode 100644 index 0000000000..dd17be2a62 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/controller.ts @@ -0,0 +1,211 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { writeFile } from 'node:fs/promises'; +import { chromium } from 'playwright'; +import { enableNetworkTracking, type NetworkTracker } from './network'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types'; + +export type HeadlessChromeOptions = { + scenarioTimeoutMs: number; + baseUrl: string; +}; + +export class HeadlessChromeController { + private readonly diagnostics = { + pageErrorCount: 0, + console: {} as Record, + }; + private readonly browser: Browser; + private readonly context: BrowserContext; + public readonly page: Page; + private readonly cdp: CDPSession; + private networkTracker: NetworkTracker | null = null; + + private constructor( + browser: Browser, + context: BrowserContext, + page: Page, + cdp: CDPSession, + options: HeadlessChromeOptions, + ) { + this.browser = browser; + this.context = context; + this.page = page; + this.cdp = cdp; + this.page.setDefaultTimeout(options.scenarioTimeoutMs); + this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + this.page.on('pageerror', () => { + this.diagnostics.pageErrorCount++; + }); + this.page.on('console', message => { + const type = message.type(); + this.diagnostics.console[type] = (this.diagnostics.console[type] ?? 0) + 1; + }); + } + + public get networkRequests(): NetworkRequest[] { + return this.networkTracker?.networkRequests ?? []; + } + + public get webSocketConnections(): WebSocketConnection[] { + return this.networkTracker?.webSocketConnections ?? []; + } + + static async create(label: string, options: HeadlessChromeOptions): Promise { + process.stderr.write(`[${label}] Launching Playwright Chromium\n`); + const browser = await chromium.launch({ + channel: 'chromium', + headless: true, + args: [ + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--metrics-recording-only', + '--no-first-run', + '--no-default-browser-check', + '--no-sandbox', + ], + }); + + try { + const context = await browser.newContext({ + baseURL: options.baseUrl, + locale: 'en-US', + }); + await context.addInitScript(() => { + // @ts-expect-error Test-only runtime hint consumed by Misskey frontend code. + window.isPlaywright = true; + }); + + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + return new HeadlessChromeController(browser, context, page, cdp, options); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } + } + + static async with(label: string, options: HeadlessChromeOptions, callback: (browser: HeadlessChromeController) => T | Promise): Promise { + const browser = await HeadlessChromeController.create(label, options); + try { + return await callback(browser); + } finally { + await browser.close(); + } + } + + public async enableNetworkTracking() { + this.networkTracker = await enableNetworkTracking(this.cdp); + } + + public async waitForNetworkDetails() { + await this.networkTracker?.waitForDetails(); + } + + public async evaluate(expression: string, timeoutMs = 30_000): Promise { + return await Promise.race([ + this.page.evaluate(expression), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()), + ]) as T; + } + + public collectDiagnostics(): BrowserDiagnostics { + return { + pageErrorCount: this.diagnostics.pageErrorCount, + console: { + log: this.diagnostics.console.log ?? 0, + warning: this.diagnostics.console.warning ?? 0, + error: this.diagnostics.console.error ?? 0, + info: this.diagnostics.console.info ?? 0, + }, + }; + } + + public async collectPerformance(): Promise { + const cdpMetricsResult = await this.cdp.send('Performance.getMetrics'); + const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); + const runtimeHeap = await this.cdp.send('Runtime.getHeapUsage').catch(() => undefined); + const tabMemory = await this.collectTabMemory(); + const webVitals = await this.evaluate(`(() => { + const navigation = performance.getEntriesByType('navigation')[0]; + const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime])); + const longTasks = performance.getEntriesByType('longtask'); + const resourceEntries = performance.getEntriesByType('resource'); + return { + firstPaintMs: paintEntries['first-paint'], + firstContentfulPaintMs: paintEntries['first-contentful-paint'], + domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd, + loadEventEndMs: navigation?.loadEventEnd, + longTaskCount: longTasks.length, + longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0), + resourceEntryCount: resourceEntries.length, + domElements: document.getElementsByTagName('*').length, + }; + })()`); + + return { + cdpMetrics, + runtimeHeap, + tabMemory, + webVitals, + }; + } + + public async collectTabMemory(): Promise { + const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { + const measureMemory = performance.measureUserAgentSpecificMemory; + if (typeof measureMemory !== 'function') return {}; + const result = await measureMemory.call(performance); + return { bytes: result.bytes }; + })()`, 60_000); + + const userAgentSpecificBytes = userAgentSpecificMemory?.bytes; + if (!Number.isFinite(userAgentSpecificBytes)) { + throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes'); + } + + return { + totalBytes: userAgentSpecificBytes as number, + }; + } + + public async takeHeapSnapshot(savePath?: string) { + const chunks: string[] = []; + const onChunk = (params: { chunk: string }) => { + chunks.push(params.chunk); + }; + this.cdp.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + + let content: string; + try { + await this.cdp.send('HeapProfiler.enable'); + await this.cdp.send('HeapProfiler.collectGarbage'); + await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + content = chunks.join(''); + } finally { + // 外さないとラウンドごとに積み上がり、古い配列にチャンクを流し続ける + this.cdp.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + } + + if (savePath != null) { + await writeFile(savePath, content); + } + + return JSON.parse(content); + } + + public async close() { + await this.cdp.detach().catch(() => undefined); + await this.context.close().catch(() => undefined); + await this.browser.close().catch(() => undefined); + } +} diff --git a/packages-private/diagnostics-frontend/src/browser/diagnostics.ts b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts new file mode 100644 index 0000000000..a4c95bf842 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/diagnostics.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { median } from 'diagnostics-shared/stats'; +import type { BrowserDiagnostics } from './types'; + +export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { + const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select)); + + return { + pageErrorCount: medianOf(sample => sample.pageErrorCount), + console: { + log: medianOf(sample => sample.console.log), + warning: medianOf(sample => sample.console.warning), + error: medianOf(sample => sample.console.error), + info: medianOf(sample => sample.console.info), + }, + }; +} diff --git a/packages-private/diagnostics-frontend/src/browser/network.ts b/packages-private/diagnostics-frontend/src/browser/network.ts new file mode 100644 index 0000000000..77095ca689 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/network.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CDPSession } from 'playwright'; +import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types'; + +function normalizeHeaders(headers: Record | undefined) { + if (headers == null) return undefined; + const normalized = {} as Record; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = String(value); + } + return normalized; +} + +function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) { + if (frame?.payloadData == null) return 0; + // opcode 1 はテキストフレームで、それ以外はbase64エンコードされたバイナリとして届く + if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); + return Buffer.byteLength(frame.payloadData, 'base64'); +} + +export type NetworkTracker = { + networkRequests: NetworkRequest[]; + webSocketConnections: WebSocketConnection[]; + /** CDPへの追加問い合わせ (postDataの取得) が全て決着するまで待つ */ + waitForDetails: () => Promise; +}; + +/** + * CDPのNetworkドメインを有効化し、リクエスト/WebSocketの生ログを収集し続けるトラッカーを返す。 + */ +export async function enableNetworkTracking(cdp: CDPSession): Promise { + const networkRequests: NetworkRequest[] = []; + const webSocketConnections: WebSocketConnection[] = []; + const requests = new Map(); + const webSockets = new Map(); + const pendingDetailReads: Promise[] = []; + + const readRequestBody = (row: NetworkRequest) => { + if (!row.hasRequestBody || row.requestBody != null) return; + const pending = cdp.send('Network.getRequestPostData', { + requestId: row.requestId, + }).then(result => { + row.requestBody = result.postData; + }).catch(() => { + // Some requests expose hasPostData but no longer have retrievable body data. + }); + pendingDetailReads.push(pending); + }; + + cdp.on('Network.requestWillBeSent', params => { + if (params.request?.url == null) return; + const row: NetworkRequest = { + requestId: params.requestId, + url: params.request.url, + method: params.request.method ?? 'GET', + resourceType: params.type ?? 'Other', + startedAt: params.timestamp ?? 0, + documentUrl: params.documentURL, + requestHeaders: normalizeHeaders(params.request.headers), + requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined, + hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string', + encodedDataLength: 0, + decodedBodyLength: 0, + fromDiskCache: false, + fromServiceWorker: false, + finished: false, + failed: false, + }; + requests.set(params.requestId, row); + networkRequests.push(row); + }); + + cdp.on('Network.webSocketCreated', params => { + if (params.requestId == null || params.url == null) return; + const row: WebSocketConnection = { + requestId: params.requestId, + url: params.url, + // Network.webSocketCreated はtimestampを持たないので、closedAt との差分は取れない + createdAt: 0, + sentFrameCount: 0, + receivedFrameCount: 0, + sentBytes: 0, + receivedBytes: 0, + errorCount: 0, + }; + webSockets.set(params.requestId, row); + webSocketConnections.push(row); + }); + + cdp.on('Network.webSocketWillSendHandshakeRequest', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); + }); + + cdp.on('Network.webSocketHandshakeResponseReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeResponseStatus = params.response?.status; + row.handshakeResponseStatusText = params.response?.statusText; + row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers); + }); + + cdp.on('Network.webSocketFrameSent', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.sentFrameCount += 1; + row.sentBytes += webSocketFramePayloadBytes(params.response); + }); + + cdp.on('Network.webSocketFrameReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.receivedFrameCount += 1; + row.receivedBytes += webSocketFramePayloadBytes(params.response); + }); + + cdp.on('Network.webSocketFrameError', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.errorCount += 1; + }); + + cdp.on('Network.webSocketClosed', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.closedAt = params.timestamp ?? 0; + }); + + cdp.on('Network.responseReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.status = params.response?.status; + row.statusText = params.response?.statusText; + row.mimeType = params.response?.mimeType; + row.responseHeaders = normalizeHeaders(params.response?.headers); + row.protocol = params.response?.protocol; + row.remoteIPAddress = params.response?.remoteIPAddress; + row.remotePort = params.response?.remotePort; + row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders); + row.fromDiskCache = params.response?.fromDiskCache === true; + row.fromServiceWorker = params.response?.fromServiceWorker === true; + }); + + cdp.on('Network.dataReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.decodedBodyLength += params.dataLength ?? 0; + row.encodedDataLength += params.encodedDataLength ?? 0; + }); + + cdp.on('Network.loadingFinished', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.finished = true; + row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); + }); + + cdp.on('Network.loadingFailed', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.failed = true; + row.finished = true; + row.errorText = params.errorText; + readRequestBody(row); + }); + + await cdp.send('Network.enable'); + await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); + await cdp.send('Network.setBypassServiceWorker', { bypass: true }); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Performance.enable'); + + return { + networkRequests, + webSocketConnections, + waitForDetails: async () => { + // 待っている最中にさらにpendingが増えることがあるので、増えなくなるまで繰り返す + let settledCount = 0; + while (settledCount < pendingDetailReads.length) { + const pending = pendingDetailReads.slice(settledCount); + settledCount = pendingDetailReads.length; + await Promise.allSettled(pending); + } + }, + }; +} + +function isMeasurableRequest(row: NetworkRequest) { + return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:'); +} + +export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary { + const origin = new URL(baseUrl).origin; + const rows = requestRows.filter(isMeasurableRequest); + const byResourceType = {} as NetworkSummary['byResourceType']; + + for (const row of rows) { + const summary = byResourceType[row.resourceType] ?? { + requests: 0, + encodedBytes: 0, + decodedBodyBytes: 0, + }; + summary.requests += 1; + summary.encodedBytes += row.encodedDataLength; + summary.decodedBodyBytes += row.decodedBodyLength; + byResourceType[row.resourceType] = summary; + } + + function isSameOrigin(url: string) { + try { + return new URL(url).origin === origin; + } catch { + return false; + } + } + + return { + requestCount: rows.length, + webSocketConnectionCount: webSocketRows == null + ? rows.filter(row => row.resourceType === 'WebSocket').length + : webSocketRows.length, + webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0, + webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0, + finishedRequestCount: rows.filter(row => row.finished).length, + failedRequestCount: rows.filter(row => row.failed).length, + cachedRequestCount: rows.filter(row => row.fromDiskCache).length, + serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length, + totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0), + totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0), + sameOriginEncodedBytes: rows + .filter(row => isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + thirdPartyEncodedBytes: rows + .filter(row => !isSameOrigin(row.url)) + .reduce((sum, row) => sum + row.encodedDataLength, 0), + byResourceType, + largestRequests: rows + .toSorted((a, b) => b.encodedDataLength - a.encodedDataLength) + .slice(0, 15) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + status: row.status, + encodedBytes: row.encodedDataLength, + decodedBodyBytes: row.decodedBodyLength, + })), + failedRequests: rows + .filter(row => row.failed) + .map(row => ({ + url: row.url, + method: row.method, + resourceType: row.resourceType, + errorText: row.errorText, + status: row.status, + })), + }; +} diff --git a/packages-private/diagnostics-frontend/src/browser/report/html-styles.ts b/packages-private/diagnostics-frontend/src/browser/report/html-styles.ts new file mode 100644 index 0000000000..8e8603ba51 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/report/html-styles.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** 差分HTMLは単体のファイルとして配布されるので、CSSも埋め込みで持つ */ +export const networkDiffHtmlStyles = ` :root { + color-scheme: light dark; + --bg: #f7f7f8; + --fg: #202124; + --muted: #5f6368; + --card: #ffffff; + --border: #dfe1e5; + --added: #137333; + --added-bg: #e6f4ea; + --removed: #a50e0e; + --removed-bg: #fce8e6; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #111315; + --fg: #e8eaed; + --muted: #bdc1c6; + --card: #1b1d20; + --border: #3c4043; + --added-bg: #17351f; + --removed-bg: #3c1f1d; + } + } + body { + margin: 0; + font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--fg); + } + main { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + } + h1 { + font-size: 24px; + margin: 0 0 8px; + } + h2 { + font-size: 18px; + margin: 32px 0 8px; + } + .meta { + color: var(--muted); + margin: 0 0 24px; + } + .summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin: 24px 0; + } + .summary > div, .request, table { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + } + .summary > div { + padding: 14px; + } + .label { + display: block; + color: var(--muted); + font-size: 12px; + } + .summary strong { + display: block; + font-size: 24px; + margin-top: 4px; + } + .added-text { + color: var(--added); + } + .removed-text { + color: var(--removed); + } + table { + border-collapse: collapse; + width: 100%; + overflow: hidden; + } + th, td { + border-bottom: 1px solid var(--border); + padding: 8px 10px; + text-align: left; + } + th { + color: var(--muted); + font-weight: 600; + } + .num { + text-align: right; + } + .requests { + display: grid; + gap: 12px; + } + .request { + padding: 14px; + overflow-wrap: anywhere; + } + .request.added { + border-left: 4px solid var(--added); + } + .request.removed { + border-left: 4px solid var(--removed); + } + .request header { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; + } + .badge, .method, .type, .status { + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + } + .added .badge { + background: var(--added-bg); + color: var(--added); + } + .removed .badge { + background: var(--removed-bg); + color: var(--removed); + } + .method, .type, .status { + background: color-mix(in srgb, var(--muted) 14%, transparent); + color: var(--fg); + } + .url { + display: block; + margin: 8px 0 12px; + color: inherit; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + } + dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 16px; + margin: 0 0 12px; + } + dl div { + min-width: 0; + } + dt { + color: var(--muted); + font-size: 12px; + } + dd { + margin: 0; + } + details { + margin-top: 8px; + } + summary { + cursor: pointer; + color: var(--muted); + } + pre { + white-space: pre-wrap; + overflow-x: auto; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border-radius: 6px; + padding: 10px; + } + .empty { + color: var(--muted); + }`; diff --git a/packages-private/diagnostics-frontend/src/browser/report/html.ts b/packages-private/diagnostics-frontend/src/browser/report/html.ts new file mode 100644 index 0000000000..ff1ca47839 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/report/html.ts @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatBytes, formatNumber } from 'diagnostics-shared/format'; +import { type Raw, html, joinHtml, raw } from 'diagnostics-shared/html'; +import { networkDiffHtmlStyles } from './html-styles'; +import type { BrowserMeasurementSample, BrowserMetricsReport, NetworkRequest } from '../types'; + +type DiffDirection = 'added' | 'removed'; + +type RequestDiff = { + direction: DiffDirection; + round: number; + baseCount: number; + headCount: number; + request: NetworkRequest; +}; + +function isHttpRequest(request: NetworkRequest) { + try { + const { protocol } = new URL(request.url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +function requestKey(request: NetworkRequest) { + // URLに現れない文字で区切らないと、区切り文字を含むURLが別のキーと衝突しうる + return [ + request.method, + request.resourceType, + request.url, + ].join('\u0000'); +} + +function groupRequests(requests: NetworkRequest[] | undefined) { + const grouped = new Map(); + for (const request of requests ?? []) { + if (!isHttpRequest(request)) continue; + const key = requestKey(request); + const rows = grouped.get(key) ?? []; + rows.push(request); + grouped.set(key, rows); + } + return grouped; +} + +function byRound(samples: BrowserMeasurementSample[]) { + return new Map(samples.map(sample => [sample.round, sample])); +} + +/** + * 同じラウンドどうしで、同一 (method, resourceType, URL) のリクエスト本数を突き合わせる。 + * 本数が増えていればhead側の増分を added、減っていればbase側の余りを removed として扱う。 + */ +function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) { + const baseRequests = groupRequests(baseSample?.networkRequests); + const headRequests = groupRequests(headSample?.networkRequests); + const keys = [...new Set([ + ...baseRequests.keys(), + ...headRequests.keys(), + ])].toSorted(); + const diffs: RequestDiff[] = []; + + for (const key of keys) { + const baseRows = baseRequests.get(key) ?? []; + const headRows = headRequests.get(key) ?? []; + if (headRows.length > baseRows.length) { + for (const request of headRows.slice(baseRows.length)) { + diffs.push({ + direction: 'added', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } else if (baseRows.length > headRows.length) { + for (const request of baseRows.slice(headRows.length)) { + diffs.push({ + direction: 'removed', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } + } + + return diffs; +} + +function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const baseSamples = byRound(base.samples); + const headSamples = byRound(head.samples); + const rounds = [...new Set([ + ...baseSamples.keys(), + ...headSamples.keys(), + ])].toSorted((a, b) => a - b); + return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round))); +} + +function formatMaybeJson(value: string | undefined) { + if (value == null || value === '') return null; + try { + return JSON.stringify(JSON.parse(value), null, '\t'); + } catch { + return value; + } +} + +function formatHeaders(headers: Record | undefined) { + if (headers == null || Object.keys(headers).length === 0) return null; + return JSON.stringify(headers, null, '\t'); +} + +function countBy(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) { + const counts = new Map(); + for (const diff of diffs) { + counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1); + } + return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]): Raw { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + const typeCounts = countBy(diffs, diff => diff.request.resourceType); + const typeRows = joinHtml(typeCounts.map(([type, count]) => html` + + ${type} + ${formatNumber(count)} + `), ''); + + return html` +
+
+ Base samples + ${formatNumber(base.sampleCount)} +
+
+ Head samples + ${formatNumber(head.sampleCount)} +
+
+ Added in Head + ${formatNumber(added)} +
+
+ Removed in Head + ${formatNumber(removed)} +
+
+ ${typeCounts.length === 0 ? raw('') : html` +
+

Diffs by Resource Type

+ + + ${typeRows} + +
TypeDiff requests
+
`}`; +} + +function renderDetails(title: string, content: string | null, open = false): Raw { + if (content == null || content === '') return raw(''); + return html` + + ${title} +
${content}
+ `; +} + +function renderRequest(diff: RequestDiff): Raw { + const { request } = diff; + const requestBody = formatMaybeJson(request.requestBody); + const requestHeaders = formatHeaders(request.requestHeaders); + const responseHeaders = formatHeaders(request.responseHeaders); + const bodyNote = requestBody == null && request.hasRequestBody === true + ? html`

Request body was present but could not be retrieved from CDP.

` + : raw(''); + + return html` +
+
+ ${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} + ${request.method} + ${request.resourceType} + ${request.status ?? '-'} +
+ ${request.url} +
+
Round
${formatNumber(diff.round)}
+
Base count
${formatNumber(diff.baseCount)}
+
Head count
${formatNumber(diff.headCount)}
+
Encoded
${formatBytes(request.encodedDataLength ?? 0)}
+
Decoded body
${formatBytes(request.decodedBodyLength ?? 0)}
+
MIME
${request.mimeType ?? '-'}
+
Protocol
${request.protocol ?? '-'}
+
Remote
${request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`}
+
Failed
${request.failed ? (request.errorText ?? 'yes') : 'no'}
+
+ ${bodyNote} + ${renderDetails('Request body', requestBody, requestBody != null)} + ${renderDetails('Request headers', requestHeaders)} + ${renderDetails('Response headers', responseHeaders)} +
`; +} + +function renderRound(round: number, diffs: RequestDiff[]): Raw { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + return html` +
+

Round ${formatNumber(round)}

+

${formatNumber(added)} added, ${formatNumber(removed)} removed

+
+ ${joinHtml(diffs.map(renderRequest), '\n')} +
+
`; +} + +export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const diffs = diffReports(base, head); + const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); + const generatedAt = new Date().toISOString(); + const content = diffs.length === 0 + ? html`

No added or removed HTTP(S) requests were found in paired samples.

` + : joinHtml(rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))), '\n'); + + return String(html` + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at ${generatedAt}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

+ ${renderSummary(base, head, diffs)} + ${content} +
+ + +`); +} diff --git a/packages-private/diagnostics-frontend/src/browser/scenario.ts b/packages-private/diagnostics-frontend/src/browser/scenario.ts new file mode 100644 index 0000000000..80df8532ec --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/scenario.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared'; +import { sleep } from './server'; +import type { HeadlessChromeController } from './controller'; + +export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible'; + +/** + * 各ラウンドを同じ初期状態から始めるため、DBを消して管理者だけ作り直す。 + */ +export async function prepareInstance(baseUrl: string) { + await resetState(baseUrl); + await registerUser(baseUrl, 'admin', 'admin1234', true); +} + +export async function runSignupAndPostScenario(chrome: HeadlessChromeController, baseUrl: string) { + const page = chrome.page; + const noteText = `Frontend browser metrics ${Date.now()}`; + + await visitHome(page, baseUrl); + await signupThroughUi(page, { username: 'alice', password: 'password' }); + await closeUserSetupDialog(page); + await postNote(page, noteText, 10_000); + + // 投稿直後の非同期処理が落ち着いてから計測したいので少し待つ + await sleep(1000); +} diff --git a/packages-private/diagnostics-frontend/src/browser/server.ts b/packages-private/diagnostics-frontend/src/browser/server.ts new file mode 100644 index 0000000000..72f7a6f79e --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/server.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; + +export function sleep(ms: number) { + return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); +} + +function commandName(command: string) { + if (process.platform !== 'win32') return command; + if (command === 'pnpm') return 'pnpm.cmd'; + return command; +} + +/** + * 計測対象のリポジトリで Misskey テストサーバーを起動する。 + * POSIXでは detached にして、後で子孫プロセスごとまとめて落とせるようにする。 + */ +export function startServer(label: string, repoDir: string) { + process.stderr.write(`[${label}] Starting Misskey test server\n`); + const child = spawn(commandName('pnpm'), ['start:test'], { + cwd: repoDir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`)); + return child; +} + +const serverStartupTimeoutMs = 120_000; + +function hasExited(child: ChildProcess) { + return child.exitCode != null || child.signalCode != null; +} + +export async function waitForServer(baseUrl: string, child: ChildProcess) { + const startedAt = Date.now(); + while (Date.now() - startedAt < serverStartupTimeoutMs) { + if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`); + try { + // 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう + const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt); + const response = await fetch(`${baseUrl}/`, { + redirect: 'manual', + signal: AbortSignal.timeout(remainingMs), + }); + if (response.status < 500) return; + } catch { + // 中断・接続拒否いずれもまだ起動中とみなしてリトライする + } + await sleep(1_000); + } + throw new Error(`Timed out waiting for ${baseUrl}`); +} + +export async function stopServer(child: ChildProcess) { + if (hasExited(child)) return; + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else if (child.pid != null) { + try { + // プロセスグループごと落とさないと pnpm 配下の node が残る + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } + + await new Promise(resolvePromise => { + if (hasExited(child)) { + resolvePromise(); + return; + } + + const forceKillTimer = setTimeout(() => { + // 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある + if (!hasExited(child) && child.pid != null) { + try { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + child.kill('SIGKILL'); + } + } + resolvePromise(); + }, 10_000); + forceKillTimer.unref(); + + child.once('exit', () => { + clearTimeout(forceKillTimer); + resolvePromise(); + }); + }); +} diff --git a/packages-private/diagnostics-frontend/src/browser/summarize.ts b/packages-private/diagnostics-frontend/src/browser/summarize.ts new file mode 100644 index 0000000000..1b88002208 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/summarize.ts @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from 'diagnostics-shared/stats'; +import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot'; +import { summarizeBrowserDiagnostics } from './diagnostics'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types'; + +export type SummarizeOptions = { + url: string; + heapSnapshotBreakdownTopN: number; +}; + +/** + * 中央値に最も近いサンプルを1つ選ぶ。 + * largestRequests のように中央値を取れない項目は、代表サンプルの値をそのまま採用する。 + */ +export function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { + const medianValue = finiteMedian(samples.map(getValue), 0); + let selected: { sample: BrowserMeasurementSample; distance: number } | null = null; + + for (const sample of samples) { + const value = getValue(sample); + if (!Number.isFinite(value)) continue; + const distance = Math.abs(value - medianValue); + if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) { + selected = { + sample, + distance, + }; + } + } + + return selected?.sample ?? samples[0]; +} + +function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) { + return { + requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests), 0), + encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes), 0), + decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes), 0), + }; +} + +export function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary { + const resourceTypes = new Set(); + for (const sample of samples) { + for (const resourceType of Object.keys(sample.network.byResourceType)) { + resourceTypes.add(resourceType); + } + } + + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const byResourceType = {} as NetworkSummary['byResourceType']; + for (const resourceType of resourceTypes) { + byResourceType[resourceType] = summarizeResourceType(samples, resourceType); + } + + return { + requestCount: finiteMedian(samples.map(sample => sample.network.requestCount), 0), + webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount), 0), + webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes), 0), + webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes), 0), + finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount), 0), + failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount), 0), + cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount), 0), + serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount), 0), + totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes), 0), + totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes), 0), + sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes), 0), + thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes), 0), + byResourceType, + largestRequests: representative.network.largestRequests, + failedRequests: representative.network.failedRequests, + }; +} + +export function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] { + const cdpMetricKeys = new Set(); + for (const sample of samples) { + for (const key of Object.keys(sample.performance.cdpMetrics)) { + cdpMetricKeys.add(key); + } + } + + const cdpMetrics = {} as Record; + for (const key of cdpMetricKeys) { + cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]), 0); + } + + const webVitalKeys = [ + 'firstPaintMs', + 'firstContentfulPaintMs', + 'domContentLoadedEventEndMs', + 'loadEventEndMs', + 'longTaskCount', + 'longTaskDurationMs', + 'maxLongTaskDurationMs', + 'resourceEntryCount', + 'domElements', + ] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[]; + + const webVitals = {} as BrowserMeasurement['performance']['webVitals']; + for (const key of webVitalKeys) { + webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]), 0); + } + + return { + cdpMetrics, + runtimeHeap: { + usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize), 0), + totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize), 0), + }, + tabMemory: { + totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes), 0), + }, + webVitals, + }; +} + +export function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[], breakdownTopN: number) { + const summary = summarizeHeapSnapshotDataSamples( + samples, + sample => sample.heapSnapshot, + { breakdownTopN }, + ); + if (summary == null) throw new Error('No heap snapshot samples'); + return summary; +} + +export function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[], options: SummarizeOptions): BrowserMetricsReport { + if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`); + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const summary: BrowserMeasurement = { + label, + timestamp: new Date().toISOString(), + url: options.url, + scenario: representative.scenario, + diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)), + durationMs: finiteMedian(samples.map(sample => sample.durationMs), 0), + network: summarizeNetworkSamples(samples), + performance: summarizePerformanceSamples(samples), + heapSnapshot: summarizeHeapSnapshotSamples(samples, options.heapSnapshotBreakdownTopN), + }; + + return { + label, + timestamp: new Date().toISOString(), + url: options.url, + scenario: representative.scenario, + sampleCount: samples.length, + aggregation: 'median', + summary, + samples, + }; +} diff --git a/packages-private/diagnostics-frontend/src/browser/types.ts b/packages-private/diagnostics-frontend/src/browser/types.ts new file mode 100644 index 0000000000..78e31b76f4 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/browser/types.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; + +// NOTE: (AI向け) +// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、 +// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、 +// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。 + +export type NetworkRequest = { + requestId: string; + url: string; + method: string; + resourceType: string; + startedAt: number; + documentUrl?: string; + requestHeaders?: Record; + requestBody?: string; + hasRequestBody: boolean; + status?: number; + statusText?: string; + mimeType?: string; + responseHeaders?: Record; + protocol?: string; + remoteIPAddress?: string; + remotePort?: number; + encodedDataLength: number; + decodedBodyLength: number; + fromDiskCache: boolean; + fromServiceWorker: boolean; + finished: boolean; + failed: boolean; + errorText?: string; +}; + +export type WebSocketConnection = { + requestId: string; + url: string; + createdAt: number; + handshakeRequestHeaders?: Record; + handshakeResponseStatus?: number; + handshakeResponseStatusText?: string; + handshakeResponseHeaders?: Record; + closedAt?: number; + sentFrameCount: number; + receivedFrameCount: number; + sentBytes: number; + receivedBytes: number; + errorCount: number; +}; + +export type NetworkSummary = { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: number; + finishedRequestCount: number; + failedRequestCount: number; + cachedRequestCount: number; + serviceWorkerRequestCount: number; + totalEncodedBytes: number; + totalDecodedBodyBytes: number; + sameOriginEncodedBytes: number; + thirdPartyEncodedBytes: number; + byResourceType: Record; + largestRequests: { + url: string; + method: string; + resourceType: string; + status?: number; + encodedBytes: number; + decodedBodyBytes: number; + }[]; + failedRequests: { + url: string; + method: string; + resourceType: string; + errorText?: string; + status?: number; + }[]; +}; + +export type TabMemory = { + totalBytes: number; +}; + +export type BrowserDiagnostics = { + pageErrorCount: number; + console: Record<'log' | 'warning' | 'error' | 'info', number>; +}; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + diagnostics: BrowserDiagnostics; + durationMs: number; + network: NetworkSummary; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: TabMemory; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +export type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + /** ラウンド単位のリクエスト差分HTMLを描くために生ログを丸ごと保持する */ + networkRequests: NetworkRequest[]; +}; + +export type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; diff --git a/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts new file mode 100644 index 0000000000..55857305c5 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/bundle/chunk-report.ts @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaPercentInMdTable, + escapeMdTableCell, + formatBytes, +} from 'diagnostics-shared/format'; +import type { CollectedBundleReport, FileEntry } from './manifest'; + +/** + * この差分以下のチャンクは個別に出さず `(other)` にまとめる。 + * ハッシュ文字列の揺れ等でサイズが数バイト動くだけの行がノイズになるため。 + */ +const smallDeltaThreshold = 5; + +/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */ +const diffRowLimit = 30; + +function entryDisplayName(entry: FileEntry | undefined) { + if (entry == null) return ''; + return entry.displayName || entry.file; +} + +export function getChunkComparisonRows(keys: string[], base: Partial>, head: Partial>) { + return keys.map(key => { + const baseEntry = base[key]; + const headEntry = head[key]; + const baseSize = baseEntry?.size ?? 0; + const headSize = headEntry?.size ?? 0; + return { + key, + name: entryDisplayName(baseEntry ?? headEntry), + baseFile: baseEntry?.file, + headFile: headEntry?.file, + baseSize, + headSize, + changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged', + sortSize: Math.max(baseSize, headSize), + }; + }); +} + +export type ChunkComparisonRow = ReturnType[number]; + +export type ChunkAggregate = { + baseSize: number; + headSize: number; + baseCount: number; + headCount: number; +}; + +export function sumChunkSizes(chunks: FileEntry[]) { + return chunks.reduce((sum, chunk) => sum + chunk.size, 0); +} + +/** + * 比較キーを持たない (= base/head で対応付けできない) チャンクの合計。 + */ +export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate { + const baseGenerated = base.filter(chunk => chunk.comparisonKey == null); + const headGenerated = head.filter(chunk => chunk.comparisonKey == null); + return { + baseSize: sumChunkSizes(baseGenerated), + headSize: sumChunkSizes(headGenerated), + baseCount: baseGenerated.length, + headCount: headGenerated.length, + }; +} + +export function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold; +} + +export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0), + headSize: rows.reduce((sum, row) => sum + row.headSize, 0), + baseCount: rows.filter(row => row.baseFile != null).length, + headCount: rows.filter(row => row.headFile != null).length, + }; +} + +export function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + +export function summarizeChunkChanges(rows: ChunkComparisonRow[]) { + return { + updated: rows.filter((row) => row.changeType === 'updated').length, + added: rows.filter((row) => row.changeType === 'added').length, + removed: rows.filter((row) => row.changeType === 'removed').length, + }; +} + +export function formatChunkChangeSummary(label: string, summary: ReturnType) { + return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; +} + +/** + * 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。 + */ +export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { + return Math.abs(b.headSize - b.baseSize) - Math.abs(a.headSize - a.baseSize) + || (b.headSize - b.baseSize) - (a.headSize - a.baseSize) + || b.sortSize - a.sortSize + || a.name.localeCompare(b.name); +} + +export function chunkFileDisplay(row: ChunkComparisonRow) { + if (row.baseFile == null) return row.headFile ?? ''; + if (row.headFile == null || row.baseFile === row.headFile) return row.baseFile; + return `${row.baseFile} → ${row.headFile}`; +} + +export function chunkMarkdownTable( + rows: ChunkComparisonRow[], + total?: { baseSize: number; headSize: number }, + generated?: ChunkAggregate, + other?: ChunkAggregate, +) { + const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0); + const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0); + if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; + + const lines = [ + '| Chunk | Base | Head | Δ | Δ (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + if (total != null) { + lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`); + lines.push('| | | | | |'); + } + + for (const row of rows) { + const chunkFile = chunkFileDisplay(row); + if (row.changeType === 'added') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + } else if (row.changeType === 'removed') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + } else { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`); + } + } + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); + } + if (hasOther) { + lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`); + } + return lines.join('\n'); +} + +export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) { + const baseComparable = base.comparableChunks; + const headComparable = head.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable); + + const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); + const diffSummary = summarizeChunkChanges(changedRows); + const diffTotal = { + baseSize: sumChunkSizes(base.chunks), + headSize: sumChunkSizes(head.chunks), + }; + const diffGenerated = generatedAggregate(base.chunks, head.chunks); + const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const diffRows = largeDeltaRows.slice(0, diffRowLimit); + // 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる + const diffOther = comparisonRowsAggregate([ + ...changedRows.filter(hasSmallDelta), + ...largeDeltaRows.slice(diffRowLimit), + ]); + + const baseStartupFiles = new Set(base.startupFiles); + const headStartupFiles = new Set(head.startupFiles); + const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file)); + const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file)); + const baseStartupComparable = comparableMap(baseStartupChunks); + const headStartupComparable = comparableMap(headStartupChunks); + const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable); + const startupSummary = summarizeChunkChanges(startupComparisonRows); + const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); + const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const startupTotal = { + baseSize: sumChunkSizes(baseStartupChunks), + headSize: sumChunkSizes(headStartupChunks), + }; + const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks); + + return [ + '
', + `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, + '', + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), + '', + '
', + '', + '
', + `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, + '', + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), + '', + '_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._', + '', + '
', + '', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts new file mode 100644 index 0000000000..c6b1d3d2e6 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/bundle/fs-utils.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Windows の `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、 + * 実ファイルパスと突き合わせる前に正規化する必要がある。 + */ +export function normalizePath(filePath: string) { + return filePath.split(path.sep).join('/'); +} + +export async function fileExists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +export async function fileSize(filePath: string) { + const stat = await fs.stat(filePath); + return stat.size; +} + +export async function* traverseDirectory(dir: string): AsyncGenerator { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* traverseDirectory(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } +} diff --git a/packages-private/diagnostics-frontend/src/bundle/manifest.ts b/packages-private/diagnostics-frontend/src/bundle/manifest.ts new file mode 100644 index 0000000000..05b00c2a09 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/bundle/manifest.ts @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils'; + +export type BundleManifestChunk = { + file: string; + src?: string; + name?: string; + isEntry?: boolean; + imports?: string[]; +}; + +export type BundleManifest = Record; + +/** + * 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、 + * 代表として ja-JP のみを見る。 + */ +const locale = 'ja-JP'; + +/** + * `src` を持たないチャンクのうち、名前がビルド間で安定していて比較可能なもの。 + */ +const stableNamedChunks = new Set(['vue', 'i18n']); + +export type FileEntry = { + comparisonKey: string | null; + displayName: string; + file: string; + manifestKeys: string[]; + size: number; +}; + +export type CollectedBundleReport = { + manifest: BundleManifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + +export function findEntryKey(manifest: BundleManifest) { + const entries = Object.entries(manifest); + return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] + ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] + ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] + ?? null; +} + +/** + * ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、 + * これが取れないチャンクは base/head の対応付けができない。 + */ +export function stableChunkKey(chunk: BundleManifestChunk) { + if (chunk.src != null) return `src:${normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; +} + +/** + * 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。 + */ +export function collectStartupManifestKeys(manifest: BundleManifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); + + function visit(key: string, importedBy?: string) { + if (keys.has(key)) return; + const chunk = manifest[key]; + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey, key); + } + + visit(entryKey); + return keys; +} + +/** + * manifest 上の出力パスを実ファイルへ解決する。`scripts/` 配下はロケール別に + * 複製されて出力されるため、代表ロケールのものへ読み替える。 + */ +export async function resolveBuiltFile(outDir: string, file: string) { + if (file.startsWith('scripts/')) { + const localizedFile = file.slice('scripts/'.length); + const localizedPath = path.join(outDir, locale, localizedFile); + if (await fileExists(localizedPath)) { + return { + absolutePath: localizedPath, + relativePath: `${locale}/${localizedFile}`, + }; + } + + throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`); + } + return { + absolutePath: path.join(outDir, file), + relativePath: file, + }; +} + +export async function collectBundleReport(repoDir: string): Promise { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as BundleManifest; + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); + + for (const [manifestKey, chunk] of Object.entries(manifest)) { + if (!chunk.file.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } + } + + // manifest に載らないロケール別チャンクも合計サイズには含めたいので拾っておく + const localeDir = path.join(outDir, locale); + if (await fileExists(localeDir)) { + for await (const fullPath of traverseDirectory(localeDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = normalizePath(path.relative(outDir, fullPath)); + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, + displayName: relativePath, + file: relativePath, + manifestKeys: [], + size: await fileSize(fullPath), + }); + } + } + + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + + return { + manifest, + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], + }; +} diff --git a/packages-private/diagnostics-frontend/src/bundle/visualizer.ts b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts new file mode 100644 index 0000000000..199accf2d7 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/bundle/visualizer.ts @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaNumber, + calcAndFormatDeltaPercent, + formatBytes, + formatNumber, +} from 'diagnostics-shared/format'; + +/** + * rollup-plugin-visualizer が出力する `stats.json` のうち、ここで使う部分のみの型。 + */ +export type VisualizerReport = { + nodeParts?: Record; + nodeMetas?: Record; + renderedLength: number; + gzipLength: number; + brotliLength: number; + }>; + options?: Record; +}; + +type ModuleRow = { + id: string; + bundles: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; + importedByCount: number; + importedCount: number; +}; + +type BundleRow = { + id: string; + modules: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; +}; + +export function collectVisualizerReport(data: VisualizerReport) { + const nodeParts = data.nodeParts ?? {}; + const nodeMetas = Object.values(data.nodeMetas ?? {}); + const moduleRows: ModuleRow[] = []; + const bundleMap = new Map(); + + for (const meta of nodeMetas) { + const row: ModuleRow = { + id: meta.id, + bundles: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + importedByCount: meta.importedBy?.length ?? 0, + importedCount: meta.imported?.length ?? 0, + }; + + for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { + const part = nodeParts[partUid]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (part == null) continue; + + row.bundles += 1; + row.renderedLength += part.renderedLength; + row.gzipLength += part.gzipLength; + row.brotliLength += part.brotliLength; + + const bundle = bundleMap.get(bundleId) ?? { + id: bundleId, + modules: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + }; + bundle.modules += 1; + bundle.renderedLength += part.renderedLength; + bundle.gzipLength += part.gzipLength; + bundle.brotliLength += part.brotliLength; + bundleMap.set(bundleId, bundle); + } + + // どのバンドルにも含まれないモジュール (tree-shake 済み等) は集計対象外 + if (row.bundles > 0) { + moduleRows.push(row); + } + } + + let staticImports = 0; + let dynamicImports = 0; + for (const meta of nodeMetas) { + for (const imported of meta.imported ?? []) { + if (imported.dynamic) { + dynamicImports += 1; + } else { + staticImports += 1; + } + } + } + + const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); + const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); + const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); + const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); + + return { + options: data.options ?? {}, + summary: { + bundles: bundleRows.length, + modules: moduleRows.length, + entries: nodeMetas.filter((meta) => meta.isEntry).length, + externals: nodeMetas.filter((meta) => meta.isExternal).length, + staticImports, + dynamicImports, + }, + metrics: { + renderedLength: totalRendered, + gzipLength: totalGzip, + brotliLength: totalBrotli, + }, + hotModules, + }; +} + +/** + * NOTE: 以前はこの関数が `string[]` を返しており、呼び出し側の `[...].join('\n')` の + * 要素として配列のまま埋め込まれていたため、テーブルがカンマ区切りの1行に潰れていた。 + * 呼び出し側で意識しなくて済むよう、ここで文字列にして返す。 + */ +export function renderVisualizerSummaryTable(base: ReturnType, head: ReturnType) { + const summary = [ + 'bundles', + 'modules', + 'entries', + //'externals', + 'staticImports', + 'dynamicImports', + ] as const; + + const metrics = [ + 'renderedLength', + 'gzipLength', + 'brotliLength', + ] as const; + + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Base${formatNumber(base.summary[key])}${formatBytes(base.metrics[key])}
Head${formatNumber(head.summary[key])}${formatBytes(head.metrics[key])}
Δ${calcAndFormatDeltaNumber(base.summary[key], head.summary[key], 0)}${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}
Δ (%)${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}
', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend/src/inspect-browser.ts b/packages-private/diagnostics-frontend/src/inspect-browser.ts new file mode 100644 index 0000000000..25d2703e99 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/inspect-browser.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; +import { HeadlessChromeController } from './browser/controller'; +import { summarizeNetwork } from './browser/network'; +import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './browser/scenario'; +import { startServer, stopServer, waitForServer } from './browser/server'; +import { selectRepresentativeSample, summarizeSamples } from './browser/summarize'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; + +type Label = 'base' | 'head'; + +const labels = ['base', 'head'] as const satisfies readonly Label[]; + +const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812'; +const sampleCount = readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); +const heapSnapshotBreakdownTopN = readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); + +// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す +const heapSnapshotOutputDir = resolve(readOptionalEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd()); +const heapSnapshotWorkDirs = { + base: join(heapSnapshotOutputDir, 'frontend-browser-base-heap-snapshots'), + head: join(heapSnapshotOutputDir, 'frontend-browser-head-heap-snapshots'), +} as const; +const heapSnapshotOutputPaths = { + base: join(heapSnapshotOutputDir, 'base-heap-snapshot.heapsnapshot'), + head: join(heapSnapshotOutputDir, 'head-heap-snapshot.heapsnapshot'), +} as const; + +function heapSnapshotPath(label: Label, round: number) { + return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); +} + +/** + * ブラウザを毎ラウンド立ち上げ直して1サンプル分を計測する。 + * ブラウザを使い回すとキャッシュや前ラウンドのGC残渣が乗るため、必ず作り直す。 + */ +async function measureSample(label: Label, round: number, heapSnapshotSavePath: string): Promise { + await prepareInstance(baseUrl); + + return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => { + await chrome.enableNetworkTracking(); + + const startedAt = Date.now(); + await runSignupAndPostScenario(chrome, baseUrl); + const durationMs = Date.now() - startedAt; + + await chrome.waitForNetworkDetails(); + const performance = await chrome.collectPerformance(); + const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); + + return { + label, + round, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: scenarioDescription, + diagnostics: chrome.collectDiagnostics(), + durationMs, + network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), + networkRequests: chrome.networkRequests, + performance, + heapSnapshot: analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }), + }; + }); +} + +/** + * 中央値に最も近いラウンドのスナップショットだけを成果物として残し、残りは捨てる。 + */ +async function saveRepresentativeHeapSnapshot(label: Label, report: BrowserMetricsReport) { + const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); + await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]); + process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); +} + +async function genReport(label: Label, repoDir: string, outputPath: string) { + let server: ReturnType | null = null; + + try { + server = startServer(label, repoDir); + await waitForServer(baseUrl, server); + + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); + await mkdir(heapSnapshotWorkDirs[label], { recursive: true }); + + const samples: BrowserMeasurementSample[] = []; + for (let round = 1; round <= sampleCount; round++) { + process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`); + samples.push(await measureSample(label, round, heapSnapshotPath(label, round))); + } + + const report = summarizeSamples(label, samples, { url: baseUrl, heapSnapshotBreakdownTopN }); + await writeFile(outputPath, JSON.stringify(report, null, '\t')); + process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); + + await saveRepresentativeHeapSnapshot(label, report); + } finally { + if (server != null) await stopServer(server); + } +} + +async function main() { + const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); + if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { + throw new Error('Usage: inspect-browser '); + } + + for (const label of labels) { + await rm(heapSnapshotOutputPaths[label], { force: true }); + } + + // base / head は同じポートでサーバーを立てるため、並行させず順番に計測する + await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); + await genReport('head', resolve(headDirArg), resolve(headOutputArg)); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend/src/render-browser-html.ts b/packages-private/diagnostics-frontend/src/render-browser-html.ts new file mode 100644 index 0000000000..c6567e088e --- /dev/null +++ b/packages-private/diagnostics-frontend/src/render-browser-html.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { renderBrowserDiagnosticsHtml } from './browser/report/html'; +import type { BrowserMetricsReport } from './browser/types'; + +async function main() { + const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); + if (baseFileArg == null || headFileArg == null || outputFileArg == null) { + throw new Error('Usage: render-browser-html '); + } + + const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; + + await writeFile(resolve(outputFileArg), renderBrowserDiagnosticsHtml(base, head)); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend/src/render-md.ts b/packages-private/diagnostics-frontend/src/render-md.ts new file mode 100644 index 0000000000..a9cfbc1cd1 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/render-md.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; +import { collectBundleReport } from './bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from './report'; +import type { BrowserMetricsReport } from './browser/types'; +import type { VisualizerReport } from './bundle/visualizer'; + +const args = process.argv.slice(2); +if (args.length !== 7) { + throw new Error('Usage: render-md '); +} +const [ + baseDirArg, + headDirArg, + baseBundleStatsFileArg, + headBundleStatsFileArg, + baseBrowserFileArg, + headBrowserFileArg, + outputFileArg, +] = args as [string, string, string, string, string, string, string]; + +const [ + baseBundle, + headBundle, + baseBundleStatsJson, + headBundleStatsJson, + baseBrowserJson, + headBrowserJson, +] = await Promise.all([ + collectBundleReport(resolve(baseDirArg)), + collectBundleReport(resolve(headDirArg)), + readFile(resolve(baseBundleStatsFileArg), 'utf8'), + readFile(resolve(headBundleStatsFileArg), 'utf8'), + readFile(resolve(baseBrowserFileArg), 'utf8'), + readFile(resolve(headBrowserFileArg), 'utf8'), +]); + +await writeFile( + resolve(outputFileArg), + renderFrontendDiagnosticsMarkdown({ + bundle: { + base: baseBundle, + head: headBundle, + baseStats: JSON.parse(baseBundleStatsJson) as VisualizerReport, + headStats: JSON.parse(headBundleStatsJson) as VisualizerReport, + visualizerArtifactUrl: readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'), + }, + browser: { + base: JSON.parse(baseBrowserJson) as BrowserMetricsReport, + head: JSON.parse(headBrowserJson) as BrowserMetricsReport, + baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), + headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), + detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), + }, + }), +); diff --git a/packages-private/diagnostics-frontend/src/report.ts b/packages-private/diagnostics-frontend/src/report.ts new file mode 100644 index 0000000000..e605b0f931 --- /dev/null +++ b/packages-private/diagnostics-frontend/src/report.ts @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format'; +import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; +import { pairedDeltaSummary } from 'diagnostics-shared/stats'; +import { renderFrontendChunkReport } from './bundle/chunk-report'; +import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer'; +import type { CollectedBundleReport } from './bundle/manifest'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from './browser/types'; + +export type FrontendDiagnosticsMarkdownInput = { + bundle: { + base: CollectedBundleReport; + head: CollectedBundleReport; + baseStats: VisualizerReport; + headStats: VisualizerReport; + /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ + visualizerArtifactUrl: string; + }; + browser: { + base: BrowserMetricsReport; + head: BrowserMetricsReport; + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; + detailedHtmlUrl?: string | null; + }; +}; + +function renderMetricRow( + label: string, + base: BrowserMetricsReport, + head: BrowserMetricsReport, + getSummaryValue: (summary: BrowserMeasurement) => number, + getSampleValue: (sample: BrowserMeasurementSample) => number, + formatter: (value: number) => string, + significantThreshold = 0, + skipIfNotSignificant = true, +) { + const baseValue = getSummaryValue(base.summary); + const headValue = getSummaryValue(head.summary); + if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; + + const summary = pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + // 有意な閾値に満たない場合はそもそもrowとして出力しない + if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; + + const deltaMedian = formatColoredDelta(summary.median, formatter, significantThreshold); + + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${formatter(summary.mad)} | ${formatColoredDelta(summary.min, formatter, significantThreshold)} | ${formatColoredDelta(summary.max, formatter, significantThreshold)} |`; +} + +function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { + return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); +} + +function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { + return resourceTypeBytes(sample, resourceTypes); +} + +function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + //function getMetric(report: BrowserMeasurement, key: string) { + // return report.performance.cdpMetrics[key]; + //} + + const rows = [ + //renderMetricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, formatNumber, 1, !all), + //renderMetricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, formatNumber), + renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, formatBytes, 10000, !all), + renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), formatBytes, 10000, !all), + renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), formatBytes, 10000, !all), + renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), formatBytes, 10000, !all), + renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), formatBytes, 10000, !all), + renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), formatBytes, 10000, !all), + //renderMetricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), + //renderMetricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //renderMetricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, formatNumber), + //renderMetricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), + //renderMetricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //renderMetricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), formatBytes), + //renderMetricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), formatBytes), + //renderMetricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, formatBytes, 10000), + //renderMetricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, formatNumber), + //renderMetricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), formatNumber), + //renderMetricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), formatNumber), + //renderMetricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), formatNumber), + //renderMetricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), formatNumber), + //renderMetricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), + //renderMetricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, formatNumber, 1, !all), + renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, formatBytes, 10000, !all), + renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, formatBytes, 10000, !all), + renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, formatNumber, 1, !all), + renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, formatNumber, 1, !all), + renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, formatNumber, 1, !all), + renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, formatNumber, 1, !all), + renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, formatNumber, 1, !all), + renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, formatBytes, 10000, !all), + ].filter(row => row != null); + + return [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + ].join('\n'); +} + +function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other']; + const keys = [...new Set([ + ...preferredOrder, + ...Object.keys(base.summary.network.byResourceType), + ...Object.keys(head.summary.network.byResourceType), + ])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null); + + const lines = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + for (const key of keys) { + const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + lines.push(''); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(''); + } + + lines.push(''); + lines.push('
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
${key}${formatNumber(baseRow.requests)}${formatNumber(headRow.requests)}${formatColoredDelta(headRow.requests - baseRow.requests, formatNumber)}${formatBytes(baseRow.encodedBytes)}${formatBytes(headRow.encodedBytes)}${formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, formatBytes)}
'); + + return lines.join('\n'); +} + +function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { + return { + summary: report.summary.heapSnapshot, + samples: report.samples.map(sample => ({ + round: sample.round, + data: sample.heapSnapshot, + })), + }; +} + +export function renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMarkdownInput) { + const { bundle, browser } = input; + const detailedHtmlUrl = browser.detailedHtmlUrl; + const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(browser.base), toHeapSnapshotReport(browser.head)); + const lines = [ + '## 🖥 Frontend Diagnostics Report', + '', + renderBrowserSummaryTable(browser.base, browser.head), + '', + 'Only metrics showing significant changes are displayed.', + '', + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', + '
', + 'Requests by resource type', + '', + renderResourceTypeTable(browser.base, browser.head), + '', + '
', + '', + '
', + 'V8 heap snapshot statistics', + '', + heapSnapshotTable ?? '_No V8 heap snapshot data._', + '', + //renderHeapSnapshotSankey(toHeapSnapshotReport(browser.head), 'Head'), + //'', + `Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`, + '
', + '', + '### 📦 Bundle Stats', + '', + renderFrontendChunkReport(bundle.base, bundle.head), + '', + renderVisualizerSummaryTable(collectVisualizerReport(bundle.baseStats), collectVisualizerReport(bundle.headStats)), + '', + `[Open treemap HTML](${bundle.visualizerArtifactUrl})`, + '', + ]; + + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; +} diff --git a/packages-private/diagnostics-frontend/test/__snapshots__/report.md b/packages-private/diagnostics-frontend/test/__snapshots__/report.md new file mode 100644 index 0000000000..41d3aae727 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/__snapshots__/report.md @@ -0,0 +1,182 @@ +## 🖥 Frontend Diagnostics Report + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Encoded network** | 1 MB | 1.1 MB | $\color{orange}{\text{+83 KB}}$ | 816 B | $\color{orange}{\text{+82 KB}}$ | $\color{orange}{\text{+84 KB}}$ | +| **Decoded body** | 3.5 MB | 3.7 MB | $\color{orange}{\text{+277 KB}}$ | 2.7 KB | $\color{orange}{\text{+274 KB}}$ | $\color{orange}{\text{+279 KB}}$ | +| **Same-origin encoded** | 1 MB | 1.1 MB | $\color{orange}{\text{+82 KB}}$ | 800 B | $\color{orange}{\text{+81 KB}}$ | $\color{orange}{\text{+82 KB}}$ | +| **Script encoded** | 918 KB | 991 KB | $\color{orange}{\text{+73 KB}}$ | 720 B | $\color{orange}{\text{+73 KB}}$ | $\color{orange}{\text{+74 KB}}$ | +| **Page-attributed memory** | 92 MB | 99 MB | $\color{orange}{\text{+7.3 MB}}$ | 72 KB | $\color{orange}{\text{+7.3 MB}}$ | $\color{orange}{\text{+7.4 MB}}$ | + +Only metrics showing significant changes are displayed. + +[View details](https://example.invalid/html) + +
+Requests by resource type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
Script10100918 KB991 KB$\color{orange}{\text{+73 KB}}$
Stylesheet22082 KB88 KB$\color{orange}{\text{+6.5 KB}}$
Fetch66041 KB44 KB$\color{orange}{\text{+3.3 KB}}$
+ +
+ +
+V8 heap snapshot statistics + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB
± 10 KB | 1.1 MB
± 11 KB | $\text{+82 KB}$
$\color{orange}{\text{+8\\%}}$ | 800 B | $\text{+81 KB}$ | $\text{+82 KB}$ | +| | | | | | | | +|
$\color{orange}{\rule{8pt}{8pt}}$ **Code**200% → 200%
| 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 1.6 KB | $\color{orange}{\text{+162 KB}}$ | $\color{orange}{\text{+165 KB}}$ | +|
$\color{red}{\rule{8pt}{8pt}}$ **Strings**300% → 300%
| 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 2.4 KB | $\color{orange}{\text{+242 KB}}$ | $\color{orange}{\text{+247 KB}}$ | +|
$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**400% → 400%
| 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 3.2 KB | $\color{orange}{\text{+323 KB}}$ | $\color{orange}{\text{+330 KB}}$ | +|
$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**500% → 500%
| 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 4 KB | $\color{orange}{\text{+404 KB}}$ | $\color{orange}{\text{+412 KB}}$ | +|
$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**600% → 600%
| 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 4.8 KB | $\color{orange}{\text{+485 KB}}$ | $\color{orange}{\text{+494 KB}}$ | +|
$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**700% → 700%
| 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 5.6 KB | $\color{orange}{\text{+566 KB}}$ | $\color{orange}{\text{+577 KB}}$ | +|
$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**800% → 800%
| 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 6.4 KB | $\color{orange}{\text{+646 KB}}$ | $\color{orange}{\text{+659 KB}}$ | + +Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) +
+ +### 📦 Bundle Stats + +
+Chunk size diff (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | +| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +
+ +
+Startup chunk size (2 updated, 0 added, 0 removed) + +| Chunk | Base | Head | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Base2612321 KB6.3 KB5.3 KB
Head2713331 KB8.4 KB7 KB
Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
+ +[Open treemap HTML](https://example.invalid/treemap) diff --git a/packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html b/packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html new file mode 100644 index 0000000000..3e96d7cca6 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/__snapshots__/render-html.html @@ -0,0 +1,351 @@ + + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at 2026-07-18T00:00:00.000Z. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

+ +
+
+ Base samples + 3 +
+
+ Head samples + 3 +
+
+ Added in Head + 3 +
+
+ Removed in Head + 0 +
+
+ +
+

Diffs by Resource Type

+ + + + + + + + +
TypeDiff requests
Fetch3
+
+ +
+

Round 1

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
1
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 1
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+ +
+

Round 2

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
2
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 2
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+ +
+

Round 3

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
3
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 3
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+
+ + diff --git a/packages-private/diagnostics-frontend/test/browser/fixtures/base.json b/packages-private/diagnostics-frontend/test/browser/fixtures/base.json new file mode 100644 index 0000000000..af591f4615 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/fixtures/base.json @@ -0,0 +1,679 @@ +{ + "label": "base", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "sampleCount": 3, + "aggregation": "median", + "summary": { + "label": "base", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 20400, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2040, + "webSocketReceivedBytes": 9180, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1040400, + "totalDecodedBodyBytes": 3457800, + "sameOriginEncodedBytes": 1020000, + "thirdPartyEncodedBytes": 20400, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 918000, + "decodedBodyBytes": 3060000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 81600, + "decodedBodyBytes": 306000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40800, + "decodedBodyBytes": 91800 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30600000, + "totalSize": 51000000 + }, + "tabMemory": { + "totalBytes": 91800000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1020000, + "code": 2040000, + "strings": 3060000, + "jsArrays": 4080000, + "typedArrays": 5100000, + "systemObjects": 6120000, + "otherJsObjects": 7140000, + "otherNonJsObjects": 8160000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1224000, + "Other": 816000 + }, + "strings": { + "strings: alpha": 1836000, + "Other": 1224000 + }, + "jsArrays": { + "jsArrays: alpha": 2448000, + "Other": 1632000 + }, + "typedArrays": { + "typedArrays: alpha": 3060000, + "Other": 2040000 + }, + "systemObjects": { + "systemObjects: alpha": 3672000, + "Other": 2448000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4284000, + "Other": 2856000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4896000, + "Other": 3264000 + } + } + } + }, + "samples": [ + { + "label": "base", + "round": 1, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 2, + "error": 0, + "info": 2 + } + }, + "durationMs": 20200, + "network": { + "requestCount": 19, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2020, + "webSocketReceivedBytes": 9090, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1030200, + "totalDecodedBodyBytes": 3423900, + "sameOriginEncodedBytes": 1010000, + "thirdPartyEncodedBytes": 20200, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 909000, + "decodedBodyBytes": 3030000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 80800, + "decodedBodyBytes": 303000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40400, + "decodedBodyBytes": 90900 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "1-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30300000, + "totalSize": 50500000 + }, + "tabMemory": { + "totalBytes": 90900000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1010000, + "code": 2020000, + "strings": 3030000, + "jsArrays": 4040000, + "typedArrays": 5050000, + "systemObjects": 6060000, + "otherJsObjects": 7070000, + "otherNonJsObjects": 8080000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1212000, + "Other": 808000 + }, + "strings": { + "strings: alpha": 1818000, + "Other": 1212000 + }, + "jsArrays": { + "jsArrays: alpha": 2424000, + "Other": 1616000 + }, + "typedArrays": { + "typedArrays: alpha": 3030000, + "Other": 2020000 + }, + "systemObjects": { + "systemObjects: alpha": 3636000, + "Other": 2424000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4242000, + "Other": 2828000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4848000, + "Other": 3232000 + } + } + } + }, + { + "label": "base", + "round": 2, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 20400, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2040, + "webSocketReceivedBytes": 9180, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1040400, + "totalDecodedBodyBytes": 3457800, + "sameOriginEncodedBytes": 1020000, + "thirdPartyEncodedBytes": 20400, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 918000, + "decodedBodyBytes": 3060000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 81600, + "decodedBodyBytes": 306000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40800, + "decodedBodyBytes": 91800 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "2-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30600000, + "totalSize": 51000000 + }, + "tabMemory": { + "totalBytes": 91800000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1020000, + "code": 2040000, + "strings": 3060000, + "jsArrays": 4080000, + "typedArrays": 5100000, + "systemObjects": 6120000, + "otherJsObjects": 7140000, + "otherNonJsObjects": 8160000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1224000, + "Other": 816000 + }, + "strings": { + "strings: alpha": 1836000, + "Other": 1224000 + }, + "jsArrays": { + "jsArrays: alpha": 2448000, + "Other": 1632000 + }, + "typedArrays": { + "typedArrays: alpha": 3060000, + "Other": 2040000 + }, + "systemObjects": { + "systemObjects: alpha": 3672000, + "Other": 2448000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4284000, + "Other": 2856000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4896000, + "Other": 3264000 + } + } + } + }, + { + "label": "base", + "round": 3, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 4, + "error": 0, + "info": 2 + } + }, + "durationMs": 20600, + "network": { + "requestCount": 21, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2060, + "webSocketReceivedBytes": 9270, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1050600, + "totalDecodedBodyBytes": 3491700, + "sameOriginEncodedBytes": 1030000, + "thirdPartyEncodedBytes": 20600, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 927000, + "decodedBodyBytes": 3090000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 82400, + "decodedBodyBytes": 309000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 41200, + "decodedBodyBytes": 92700 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "3-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30900000, + "totalSize": 51500000 + }, + "tabMemory": { + "totalBytes": 92700000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1030000, + "code": 2060000, + "strings": 3090000, + "jsArrays": 4120000, + "typedArrays": 5150000, + "systemObjects": 6180000, + "otherJsObjects": 7210000, + "otherNonJsObjects": 8240000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1236000, + "Other": 824000 + }, + "strings": { + "strings: alpha": 1854000, + "Other": 1236000 + }, + "jsArrays": { + "jsArrays: alpha": 2472000, + "Other": 1648000 + }, + "typedArrays": { + "typedArrays: alpha": 3090000, + "Other": 2060000 + }, + "systemObjects": { + "systemObjects: alpha": 3708000, + "Other": 2472000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4326000, + "Other": 2884000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4944000, + "Other": 3296000 + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend/test/browser/fixtures/head.json b/packages-private/diagnostics-frontend/test/browser/fixtures/head.json new file mode 100644 index 0000000000..9832e65b31 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/fixtures/head.json @@ -0,0 +1,742 @@ +{ + "label": "head", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "sampleCount": 3, + "aggregation": "median", + "summary": { + "label": "head", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 22032, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2203, + "webSocketReceivedBytes": 9914, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1123632, + "totalDecodedBodyBytes": 3734424, + "sameOriginEncodedBytes": 1101600, + "thirdPartyEncodedBytes": 22032, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 991440, + "decodedBodyBytes": 3304800 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88128, + "decodedBodyBytes": 330480 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44064, + "decodedBodyBytes": 99144 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33048000, + "totalSize": 55080000 + }, + "tabMemory": { + "totalBytes": 99144000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1101600, + "code": 2203200, + "strings": 3304800, + "jsArrays": 4406400, + "typedArrays": 5508000, + "systemObjects": 6609600, + "otherJsObjects": 7711200, + "otherNonJsObjects": 8812800 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1321920, + "Other": 881280 + }, + "strings": { + "strings: alpha": 1982880, + "Other": 1321920 + }, + "jsArrays": { + "jsArrays: alpha": 2643840, + "Other": 1762560 + }, + "typedArrays": { + "typedArrays: alpha": 3304800, + "Other": 2203200 + }, + "systemObjects": { + "systemObjects: alpha": 3965760, + "Other": 2643840 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4626720, + "Other": 3084480 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5287680, + "Other": 3525120 + } + } + } + }, + "samples": [ + { + "label": "head", + "round": 1, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 2, + "error": 0, + "info": 2 + } + }, + "durationMs": 21816, + "network": { + "requestCount": 19, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2182, + "webSocketReceivedBytes": 9817, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1112616, + "totalDecodedBodyBytes": 3697812, + "sameOriginEncodedBytes": 1090800, + "thirdPartyEncodedBytes": 21816, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 981720, + "decodedBodyBytes": 3272400 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 87264, + "decodedBodyBytes": 327240 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 43632, + "decodedBodyBytes": 98172 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "1-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":1}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 32724000, + "totalSize": 54540000 + }, + "tabMemory": { + "totalBytes": 98172000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1090800, + "code": 2181600, + "strings": 3272400, + "jsArrays": 4363200, + "typedArrays": 5454000, + "systemObjects": 6544800, + "otherJsObjects": 7635600, + "otherNonJsObjects": 8726400 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1308960, + "Other": 872640 + }, + "strings": { + "strings: alpha": 1963440, + "Other": 1308960 + }, + "jsArrays": { + "jsArrays: alpha": 2617920, + "Other": 1745280 + }, + "typedArrays": { + "typedArrays: alpha": 3272400, + "Other": 2181600 + }, + "systemObjects": { + "systemObjects: alpha": 3926880, + "Other": 2617920 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4581360, + "Other": 3054240 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5235840, + "Other": 3490560 + } + } + } + }, + { + "label": "head", + "round": 2, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 22032, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2203, + "webSocketReceivedBytes": 9914, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1123632, + "totalDecodedBodyBytes": 3734424, + "sameOriginEncodedBytes": 1101600, + "thirdPartyEncodedBytes": 22032, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 991440, + "decodedBodyBytes": 3304800 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88128, + "decodedBodyBytes": 330480 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44064, + "decodedBodyBytes": 99144 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "2-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":2}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33048000, + "totalSize": 55080000 + }, + "tabMemory": { + "totalBytes": 99144000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1101600, + "code": 2203200, + "strings": 3304800, + "jsArrays": 4406400, + "typedArrays": 5508000, + "systemObjects": 6609600, + "otherJsObjects": 7711200, + "otherNonJsObjects": 8812800 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1321920, + "Other": 881280 + }, + "strings": { + "strings: alpha": 1982880, + "Other": 1321920 + }, + "jsArrays": { + "jsArrays: alpha": 2643840, + "Other": 1762560 + }, + "typedArrays": { + "typedArrays: alpha": 3304800, + "Other": 2203200 + }, + "systemObjects": { + "systemObjects: alpha": 3965760, + "Other": 2643840 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4626720, + "Other": 3084480 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5287680, + "Other": 3525120 + } + } + } + }, + { + "label": "head", + "round": 3, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 4, + "error": 0, + "info": 2 + } + }, + "durationMs": 22248, + "network": { + "requestCount": 21, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2225, + "webSocketReceivedBytes": 10012, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1134648, + "totalDecodedBodyBytes": 3771036, + "sameOriginEncodedBytes": 1112400, + "thirdPartyEncodedBytes": 22248, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 1001160, + "decodedBodyBytes": 3337200 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88992, + "decodedBodyBytes": 333720 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44496, + "decodedBodyBytes": 100116 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "3-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":3}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33372000, + "totalSize": 55620000 + }, + "tabMemory": { + "totalBytes": 100116000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1112400, + "code": 2224800, + "strings": 3337200, + "jsArrays": 4449600, + "typedArrays": 5562000, + "systemObjects": 6674400, + "otherJsObjects": 7786800, + "otherNonJsObjects": 8899200 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1334880, + "Other": 889920 + }, + "strings": { + "strings: alpha": 2002320, + "Other": 1334880 + }, + "jsArrays": { + "jsArrays: alpha": 2669760, + "Other": 1779840 + }, + "typedArrays": { + "typedArrays: alpha": 3337200, + "Other": 2224800 + }, + "systemObjects": { + "systemObjects: alpha": 4004640, + "Other": 2669760 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4672080, + "Other": 3114720 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5339520, + "Other": 3559680 + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend/test/browser/render.test.ts b/packages-private/diagnostics-frontend/test/browser/render.test.ts new file mode 100644 index 0000000000..9def362ff6 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/render.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { renderBrowserDiagnosticsHtml } from '../../src/browser/report/html'; +import type { BrowserMetricsReport } from '../../src/browser/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +beforeEach(() => { + // renderBrowserDiagnosticsHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('renders the network request diff html report', async () => { + const html = renderBrowserDiagnosticsHtml(await loadFixture('base'), await loadFixture('head')); + + await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); +}); + +test('escapes html metacharacters coming from the browser session', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる + head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; + + const html = renderBrowserDiagnosticsHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend/test/browser/server.test.ts b/packages-private/diagnostics-frontend/test/browser/server.test.ts new file mode 100644 index 0000000000..24f3e62444 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/browser/server.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, expect, test, vi } from 'vitest'; +import { stopServer, waitForServer } from '../../src/browser/server'; +import type { ChildProcess } from 'node:child_process'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawnSync: vi.fn(), + }; +}); + +function signaledChild() { + return Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: 'SIGTERM' as NodeJS.Signals, + pid: undefined, + kill: vi.fn(), + }) as unknown as ChildProcess; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('waitForServer fails immediately when the server exited by signal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 }); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForServer('http://127.0.0.1:61812', signaledChild())) + .rejects.toThrow('Misskey server exited early with signal SIGTERM'); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test('stopServer returns immediately when the server already exited by signal', async () => { + const child = signaledChild(); + const stopPromise = stopServer(child); + + expect(child.listenerCount('exit')).toBe(0); + await stopPromise; +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json new file mode 100644 index 0000000000..27f6b2d6b4 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/base-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json new file mode 100644 index 0000000000..ecddcae812 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fixtures/head-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} diff --git a/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts new file mode 100644 index 0000000000..12ec8d7bb1 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/fs-utils.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import { afterEach, expect, test, vi } from 'vitest'; +import { fileExists } from '../../src/bundle/fs-utils'; + +function fsError(code: string) { + return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test('fileExists returns false for ENOENT', async () => { + vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT')); + + await expect(fileExists('missing')).resolves.toBe(false); +}); + +test('fileExists rethrows errors other than ENOENT', async () => { + const error = fsError('EACCES'); + vi.spyOn(fs, 'access').mockRejectedValueOnce(error); + + await expect(fileExists('restricted')).rejects.toBe(error); +}); diff --git a/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts new file mode 100644 index 0000000000..ed91120f5f --- /dev/null +++ b/packages-private/diagnostics-frontend/test/bundle/manifest.test.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../../src/bundle/manifest'; + +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-')); +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend/test/report.test.ts b/packages-private/diagnostics-frontend/test/report.test.ts new file mode 100644 index 0000000000..c45961ccb9 --- /dev/null +++ b/packages-private/diagnostics-frontend/test/report.test.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectBundleReport } from '../src/bundle/manifest'; +import { renderFrontendDiagnosticsMarkdown } from '../src/report'; +import type { BrowserMetricsReport } from '../src/browser/types'; +import type { VisualizerReport } from '../src/bundle/visualizer'; + +const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures'); +const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures'); + +/** + * ビルド成果物のfixture。 + * + * `collectBundleReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず + * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 + */ +const manifest = { + 'src/_boot_.ts': { file: 'assets/boot-a1.js', src: 'src/_boot_.ts', name: 'boot', isEntry: true, imports: ['_vue.js', '_i18n.js'] }, + '_vue.js': { file: 'assets/vue-b2.js', name: 'vue' }, + // `scripts/` 配下はロケール別に出力されるので ja-JP/ に解決される + '_i18n.js': { file: 'scripts/i18n-c3.js', name: 'i18n' }, + 'src/pages/foo.vue': { file: 'assets/foo-d4.js', src: 'src/pages/foo.vue', name: 'foo' }, + // .js 以外はチャンクとして数えない + 'src/pages/style.css': { file: 'assets/style-e5.css', src: 'src/pages/style.css' }, +}; + +const fileSizes = { + base: { + 'assets/boot-a1.js': 20_000, + 'assets/vue-b2.js': 90_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + 'ja-JP/orphan.js': 1_200, + }, + head: { + // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される + 'assets/boot-a1.js': 20_003, + // 明確に増えるので diff表に行として出る + 'assets/vue-b2.js': 96_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + // manifestに載らない出力なので「(other generated chunks)」に集約される + 'ja-JP/orphan.js': 1_500, + }, +} as const satisfies Record<'base' | 'head', Record>; + +let repoDirs: { base: string; head: string }; +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-')); + + for (const label of ['base', 'head'] as const) { + const outDir = join(workDir, label, 'built/_frontend_vite_'); + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); + + for (const [file, size] of Object.entries(fileSizes[label])) { + const path = join(outDir, file); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'x'.repeat(size)); + } + } + + repoDirs = { + base: join(workDir, 'base'), + head: join(workDir, 'head'), + }; +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +async function loadBundleStats(name: 'base' | 'head') { + return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +async function loadBrowserReport(name: 'base' | 'head') { + return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +async function renderReport(detailedHtmlUrl: string | null) { + return renderFrontendDiagnosticsMarkdown({ + bundle: { + base: await collectBundleReport(repoDirs.base), + head: await collectBundleReport(repoDirs.head), + baseStats: await loadBundleStats('base'), + headStats: await loadBundleStats('head'), + visualizerArtifactUrl: 'https://example.invalid/treemap', + }, + browser: { + base: await loadBrowserReport('base'), + head: await loadBrowserReport('head'), + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl, + }, + }); +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders one frontend diagnostics markdown report from bundle and browser data', async () => { + const markdown = await renderReport('https://example.invalid/html'); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md'); +}); + +test('omits the browser details link when no detailed html artifact was uploaded', async () => { + const markdown = await renderReport(null); + + expect(markdown).not.toContain('View details'); +}); diff --git a/packages-private/diagnostics-frontend/tsconfig.json b/packages-private/diagnostics-frontend/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-shared/eslint.config.js b/packages-private/diagnostics-shared/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-shared/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-shared/package.json b/packages-private/diagnostics-shared/package.json new file mode 100644 index 0000000000..1fd0a14682 --- /dev/null +++ b/packages-private/diagnostics-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "diagnostics-shared", + "private": true, + "type": "module", + "exports": { + "./env": "./src/env.ts", + "./format": "./src/format.ts", + "./html": "./src/html.ts", + "./stats": "./src/stats.ts", + "./heap-snapshot": "./src/heap-snapshot/index.ts" + }, + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "26.1.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-shared/src/env.ts b/packages-private/diagnostics-shared/src/env.ts new file mode 100644 index 0000000000..0875cb2373 --- /dev/null +++ b/packages-private/diagnostics-shared/src/env.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function readIntegerEnv(name: string, defaultValue: number, min: number) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); + + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); + return value; +} + +export function readBooleanEnv(name: string, defaultValue: boolean) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (rawValue === '1' || rawValue === 'true') return true; + if (rawValue === '0' || rawValue === 'false') return false; + throw new Error(`${name} must be one of: 1, 0, true, false`); +} + +/** + * 必須の環境変数を読む。未設定・空文字ならエラーにする。 + */ +export function readRequiredEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`); + return rawValue; +} + +/** + * 任意の環境変数を読む。未設定・空文字なら null を返す。 + */ +export function readOptionalEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') return null; + return rawValue; +} diff --git a/packages-private/diagnostics-shared/src/format.ts b/packages-private/diagnostics-shared/src/format.ts new file mode 100644 index 0000000000..75dc835e7b --- /dev/null +++ b/packages-private/diagnostics-shared/src/format.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}); + +export function escapeLatex(text: string) { + return text + .replaceAll('\\', '\\\\') + .replaceAll('{', '\\{') + .replaceAll('}', '\\}') + .replaceAll('%', '\\%'); +} + +export function escapeMdTableCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +export function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); +} + +export function formatNumber(value: number) { + return numberFormatter.format(value); +} + +export function formatBytes(value: number) { + if (value === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = value; + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; + unitIndex += 1; + } + + const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; +} + +/** + * KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。 + */ +export function formatKiBAsMb(valueKiB: number | null | undefined) { + if (valueKiB == null || !Number.isFinite(valueKiB)) return '-'; + return `${formatNumber(valueKiB / 1000)} MB`; +} + +export function formatPercent(value: number) { + return `${formatNumber(value)}%`; +} + +export function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; + return `${formatNumber(value)} ms`; +} + +export function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +/** + * 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。 + */ +export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return text(0); + const sign = delta > 0 ? '+' : '-'; + if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; + const color = delta > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; +} + +export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { + return formatColoredDelta(deltaBytes, formatBytes, colorThreshold); +} + +export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { + return formatColoredDelta(deltaPercent, formatPercent, colorThreshold); +} + +/** + * Markdownのテーブルセル内に置く差分パーセント。 + * LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。 + */ +export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) { + return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%'); +} + +export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatColoredDelta(after - before, formatNumber, colorThreshold); +} + +export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatDeltaBytes(after - before, colorThreshold); +} + +export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || before === 0 || after == null) return '-'; + return formatDeltaPercent((after - before) / before * 100, colorThreshold); +} + +export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts new file mode 100644 index 0000000000..b3443e1889 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown'; +import { + createEmptyHeapSnapshotData, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +/** + * `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。 + */ +type HeapSnapshotView = { + nodes: number[]; + edges: number[]; + strings: string[]; + nodeFieldCount: number; + edgeFieldCount: number; + nodeTypeNames: string[]; + edgeTypeNames: string[]; + typeOffset: number; + nameOffset: number; + selfSizeOffset: number; + edgeCountOffset: number; + edgeTypeOffset: number; + edgeNameOffset: number; + edgeToNodeOffset: number; + extraNativeBytes: number; +}; + +function requireOffsets(fields: string[], names: string[], what: string) { + const offsets = names.map(name => fields.indexOf(name)); + if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`); + return offsets; +} + +function parseHeapSnapshot(snapshot: any): HeapSnapshotView { + const meta = snapshot?.snapshot?.meta; + const { nodes, edges, strings } = snapshot ?? {}; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const [typeOffset, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node'); + const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge'); + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + return { + nodes, + edges, + strings, + nodeFieldCount: nodeFields.length, + edgeFieldCount: edgeFields.length, + nodeTypeNames, + edgeTypeNames, + typeOffset, + nameOffset, + selfSizeOffset, + edgeCountOffset, + edgeTypeOffset, + edgeNameOffset, + edgeToNodeOffset, + extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0, + }; +} + +/** + * ノードごとのedge開始位置と、各ノードが何本のedgeから参照されているかを求める。 + * JS配列のelementsストアが専有されているか判定するために使う。 + */ +function indexEdges(view: HeapSnapshotView) { + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + + for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) { + const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + return { edgeStartIndexes, retainerCounts }; +} + +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const view = parseHeapSnapshot(snapshot); + const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view; + + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of heapSnapshotBreakdownCategories) { + breakdowns[category] = {}; + } + + const { edgeStartIndexes, retainerCounts } = indexEdges(view); + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) { + if (value <= 0) return; + categories[category] += value; + const label = classifyHeapSnapshotBreakdown(category, type, name); + breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value; + if (counted) nodeCounts[category]++; + } + + /** + * 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、 + * その配列だけが参照しているelementsノードの分を JS arrays に加算する。 + */ + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue; + if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements'); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (view.extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + view.typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name); + } else if (typeId === nativeType) { + addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name); + } else if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name); + } else if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name); + } else if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name); + addJsArrayElementSize(nodeIndex); + } + } + + categories.total += view.extraNativeBytes; + + // 上のループで JS arrays に計上したelementsノードが確定してから、残りを Other JS objects に振り分ける + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + view.typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + if (name === 'Array') continue; + + addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts new file mode 100644 index 0000000000..0dd5ffe324 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + defaultHeapSnapshotBreakdownTopN, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function sanitizeLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +/** + * ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。 + */ +export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) { + switch (category) { + case 'strings': + return type; + + case 'jsArrays': + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeLabel(`${type}: ${name}`); + + case 'typedArrays': + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeLabel(`${type}: ${name}`); + + case 'systemObjects': + if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'otherJsObjects': + if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown'); + return type; + + case 'otherNonJsObjects': + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown'); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'code': { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeLabel(`code: ${name}`, 'code: unknown'); + } + + default: + return sanitizeLabel(`${type}: ${name}`, type); + } +} + +/** + * 内訳を上位N件に丸め、残りを `Other` にまとめる。 + */ +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const collapsed = Object.fromEntries(entries.slice(0, topN)); + const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts new file mode 100644 index 0000000000..4e7caed6d8 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。 +export const heapSnapshotCategory = { + total: { label: 'Total', color: 'gray', colorHex: '#888888' }, + code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, + strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, + jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, + typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, + systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, + otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, + otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, +} as const satisfies Record; + +export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory; + +export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[]; + +/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */ +export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total'); + +export type HeapSnapshotData = { + categories: Record; + nodeCounts: Record; + /** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */ + breakdowns?: Partial>>; +}; + +export type HeapSnapshotReport = { + summary: HeapSnapshotData; + samples: { + round: number; + data: HeapSnapshotData; + }[]; +}; + +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of heapSnapshotCategories) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {}, + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/index.ts b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts new file mode 100644 index 0000000000..0634efac87 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './analyze'; +export * from './breakdown'; +export * from './categories'; +export * from './render'; +export * from './summarize'; diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts new file mode 100644 index 0000000000..7690c834c5 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + formatBytes, + formatDeltaBytes, + formatDeltaPercentInMdTable, + formatPercent, +} from '../format'; +import { mad, pairedDeltaSummary } from '../stats'; +import { + heapSnapshotCategories, + heapSnapshotCategory, + type HeapSnapshotCategory, + type HeapSnapshotReport, +} from './categories'; + +/** これ未満のバイト差分には色を付けない (0.1 MB) */ +const byteColorThreshold = 100_000; + +function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + return report.summary.categories[category]; +} + +function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + const values = report.samples + .map(sample => sample.data.categories[category]) + .filter(value => Number.isFinite(value)); + if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); + + return mad(values); +} + +function swatch(category: HeapSnapshotCategory) { + return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; +} + +/** + * base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。 + */ +export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + const baseTotal = categoryValue(base, 'total'); + const headTotal = categoryValue(head, 'total'); + + for (const category of heapSnapshotCategories) { + const baseValue = categoryValue(base, category); + const headValue = categoryValue(head, category); + const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]); + const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`; + + if (category === 'total') { + // Totalだけはばらつきと変化率も併記する + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}
${formatDeltaPercentInMdTable(percent, 0.1)}`; + const baseText = `${formatBytes(baseValue)}
± ${formatBytes(categorySampleSpread(base, category))}`; + const headText = `${formatBytes(headValue)}
± ${formatBytes(categorySampleSpread(head, category))}`; + lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`); + lines.push('| | | | | | | |'); + } else { + // 各カテゴリはTotalに占める割合の推移をdetailsで見せる + const basePercent = formatPercent((baseValue * 100) / baseTotal); + const headPercent = formatPercent((headValue * 100) / headTotal); + const metricText = `
${swatch(category)}${basePercent} → ${headPercent}
`; + lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`); + } + } + + if (lines.length === 2) return null; + return lines.join('\n'); +} + +const sankeyChildMinRatio = 0.3; +const sankeyParentMinPercent = 10; + +function escapeCsvValue(value: string) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function formatSankeyPercentValue(value: number) { + const rounded = Math.round(value * 100) / 100; + if (rounded === 0 && value > 0) return '0.01'; + if (Number.isInteger(rounded)) return String(rounded); + return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +/** + * heap snapshotの構成比をmermaidのsankey図として描画する。 + * 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。 + */ +export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { + const total = categoryValue(report, 'total'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (total == null || total <= 0) return null; + + const categories = heapSnapshotCategories + .filter(category => category !== 'total') + .map(category => { + const value = categoryValue(report, category); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value == null || value <= 0) return null; + + const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {}) + .filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0) + .toSorted((a, b) => b[1] - a[1]); + const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); + const percent = (value * 100) / total; + const childEntries: [string, number][] = []; + let otherPercent = 0; + + if (breakdownTotal > 0 && percent > sankeyParentMinPercent) { + for (const [childName, childValue] of breakdownEntries) { + const childRatio = childValue / breakdownTotal; + if (childRatio >= sankeyChildMinRatio) { + childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]); + } else { + otherPercent += percent * childRatio; + } + } + + if (childEntries.length > 0 && otherPercent > 0) { + childEntries.push(['Other', otherPercent]); + } + } + + return { category, percent, childEntries }; + }) + .filter(value => value != null); + + if (categories.length === 0) return null; + + const nodeColors: Record = { + [title]: heapSnapshotCategory.total.colorHex, + Other: '#888888', + }; + for (const { category, childEntries } of categories) { + nodeColors[category] = heapSnapshotCategory[category].colorHex; + for (const [childName] of childEntries) { + nodeColors[childName] = heapSnapshotCategory[category].colorHex; + } + } + + const lines = [ + `
${title} heap snapshot composition`, + '', + '```mermaid', + `%%{init: ${JSON.stringify({ + sankey: { + showValues: false, + linkColor: 'target', + labelStyle: 'outlined', + nodeAlignment: 'center', + nodePadding: 10, + nodeColors, + }, + })}}%%`, + 'sankey-beta', + ]; + + for (const { category, percent, childEntries } of categories) { + const categoryLabel = heapSnapshotCategory[category].label; + lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`); + + for (const [childName, childPercent] of childEntries) { + lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); + } + } + + lines.push('```', '', '
'); + + return lines.join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts new file mode 100644 index 0000000000..bbc2a6d203 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from '../stats'; +import { collapseHeapSnapshotBreakdown } from './breakdown'; +import { + heapSnapshotBreakdownCategories, + heapSnapshotCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function isComplete(values: Partial>): values is Record { + return heapSnapshotCategories.every(category => values[category] != null); +} + +/** + * 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。 + * 全カテゴリ分の値が揃わなければ null を返す。 + */ +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + + const categories: Partial = {}; + const nodeCounts: Partial = {}; + for (const category of heapSnapshotCategories) { + const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (categoryValue != null) categories[category] = categoryValue; + + const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (nodeCountValue != null) nodeCounts[category] = nodeCountValue; + } + + // 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って + // undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする + if (!isComplete(categories) || !isComplete(nodeCounts)) return null; + + const breakdowns: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {}))); + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) breakdowns[category] = collapsed; + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + } satisfies HeapSnapshotData; +} diff --git a/packages-private/diagnostics-shared/src/html.ts b/packages-private/diagnostics-shared/src/html.ts new file mode 100644 index 0000000000..907c089656 --- /dev/null +++ b/packages-private/diagnostics-shared/src/html.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { escapeHtml } from './format'; + +/** + * エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。 + * + * ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に + * 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。 + * 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。 + */ +export class Raw { + constructor(private readonly value: string) {} + + toString() { + return this.value; + } +} + +/** + * 文字列をエスケープせずそのまま埋め込む。 + * 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。 + */ +export function raw(value: string) { + return new Raw(value); +} + +function interpolate(value: unknown): string { + if (value instanceof Raw) return value.toString(); + // 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する + if (Array.isArray(value)) return value.map(interpolate).join(''); + if (value == null) return ''; + return escapeHtml(value); +} + +/** + * HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。 + * エスケープしたくない場合は `raw()` で包む必要があるため、 + * 「うっかり生のまま埋め込む」ことが起きない。 + */ +export function html(strings: TemplateStringsArray, ...values: unknown[]) { + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + result += interpolate(values[i]) + strings[i + 1]; + } + return new Raw(result); +} + +/** + * 断片を指定の区切り文字で連結する。 + * `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。 + */ +export function joinHtml(parts: readonly Raw[], separator: string) { + return new Raw(parts.map(part => part.toString()).join(separator)); +} diff --git a/packages-private/diagnostics-shared/src/stats.ts b/packages-private/diagnostics-shared/src/stats.ts new file mode 100644 index 0000000000..7516242c32 --- /dev/null +++ b/packages-private/diagnostics-shared/src/stats.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function median(values: number[]) { + const sorted = values.toSorted((a, b) => a - b); + const center = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[center]; + return Math.round((sorted[center - 1] + sorted[center]) / 2); +} + +export function mad(values: number[]) { + if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); + + const center = median(values); + return median(values.map(value => Math.abs(value - center))); +} + +/** + * 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。 + */ +export function finiteMedian(values: (number | null | undefined)[]): number | null; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return median(finiteValues); +} + +/** + * サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。 + */ +export function sampleSpread(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length < 2) return null; + return mad(finiteValues); +} + +type RoundedSample = { round: number }; + +function indexByRound(samples: T[]) { + const samplesByRound = new Map(); + for (const sample of samples) { + // 負のroundはwarmupを表すため対象外 + if (sample.round <= 0) continue; + samplesByRound.set(sample.round, sample); + } + return samplesByRound; +} + +/** + * base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。 + * 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。 + */ +export function pairedDeltaSummary(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) { + const baseSamplesByRound = indexByRound(baseSamples); + const headSamplesByRound = indexByRound(headSamples); + const values: number[] = []; + + for (const [round, baseSample] of baseSamplesByRound) { + const headSample = headSamplesByRound.get(round); + if (headSample == null) continue; + + const baseValue = getValue(baseSample); + const headValue = getValue(headSample); + if (baseValue == null || headValue == null) continue; + + values.push(headValue - baseValue); + } + + // 対応するroundが1つも無いと中央値も最小/最大も定義できない。 + // 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす + if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common'); + + return { + median: median(values), + // 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する) + mad: values.length < 2 ? 0 : mad(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values.length, + }; +} diff --git a/packages-private/diagnostics-shared/test/format.test.ts b/packages-private/diagnostics-shared/test/format.test.ts new file mode 100644 index 0000000000..e391d86283 --- /dev/null +++ b/packages-private/diagnostics-shared/test/format.test.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + calcAndFormatDeltaPercent, + calcAndFormatDeltaPercentInMdTable, + escapeHtml, + escapeLatex, + escapeMdTableCell, + formatBytes, + formatColoredDelta, + formatKiBAsMb, + formatMs, + formatNumber, +} from '../src/format'; + +describe('formatBytes', () => { + // 1024ではなく1000区切り。単位が2桁以上なら小数を落とす + test.each([ + [0, '0 B'], + [999, '999 B'], + [1_000, '1 KB'], + [1_500, '1.5 KB'], + [15_000, '15 KB'], + [1_234_567, '1.2 MB'], + [12_345_678, '12 MB'], + [1_500_000_000, '1.5 GB'], + [1_500_000_000_000, '1,500 GB'], + ])('formats %i as %s', (input, expected) => { + expect(formatBytes(input)).toBe(expected); + }); +}); + +describe('formatColoredDelta', () => { + test('leaves zero uncoloured and unsigned', () => { + expect(formatColoredDelta(0, formatNumber)).toBe('0'); + }); + + test('colours growth orange and shrinkage green', () => { + expect(formatColoredDelta(5, formatNumber)).toBe('$\\color{orange}{\\text{+5}}$'); + expect(formatColoredDelta(-5, formatNumber)).toBe('$\\color{green}{\\text{-5}}$'); + }); + + test('omits colour below the threshold but keeps the sign', () => { + expect(formatColoredDelta(5, formatNumber, 10)).toBe('$\\text{+5}$'); + }); +}); + +describe('escaping', () => { + test('escapeHtml covers the five metacharacters', () => { + expect(escapeHtml(`&<>"'`)).toBe('&<>"''); + }); + + test('escapeHtml maps nullish to an empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); + + test('escapeLatex escapes braces and percent', () => { + expect(escapeLatex('100% {x}')).toBe('100\\% \\{x\\}'); + }); + + test('escapeMdTableCell keeps pipes and newlines from breaking the table', () => { + expect(escapeMdTableCell('a|b\nc')).toBe('a\\|b
c'); + }); +}); + +describe('percent helpers', () => { + // before が0だと変化率そのものが定義できない + test('returns a placeholder when the baseline is zero or missing', () => { + expect(calcAndFormatDeltaPercent(0, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(null, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(10, null)).toBe('-'); + }); + + // 0になったのは「消えた」という有効な結果なので、隠さず -100% として出す + test('formats a drop to zero as -100%', () => { + expect(calcAndFormatDeltaPercent(10, 0)).toBe('$\\color{green}{\\text{-100\\%}}$'); + }); + + // Markdownのテーブルセル内ではLaTeXの \% がさらに食われるため二重にする + test('doubles the percent escape for markdown table cells', () => { + expect(calcAndFormatDeltaPercentInMdTable(100, 110)).toContain('\\\\%'); + expect(calcAndFormatDeltaPercent(100, 110)).not.toContain('\\\\%'); + }); +}); + +describe('nullish handling', () => { + test('formatKiBAsMb and formatMs render a dash for missing values', () => { + expect(formatKiBAsMb(null)).toBe('-'); + expect(formatKiBAsMb(Number.NaN)).toBe('-'); + expect(formatMs(undefined)).toBe('-'); + }); + + test('formatMs switches to seconds past 1000ms', () => { + expect(formatMs(999)).toBe('999 ms'); + expect(formatMs(1_500)).toBe('1.5 s'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/html.test.ts b/packages-private/diagnostics-shared/test/html.test.ts new file mode 100644 index 0000000000..6e9dbc48b0 --- /dev/null +++ b/packages-private/diagnostics-shared/test/html.test.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { html, joinHtml, raw, Raw } from '../src/html'; + +describe('html', () => { + test('escapes interpolated values by default', () => { + const value = ''; + expect(String(html`

${value}

`)).toBe('

<script>alert(1)</script>

'); + }); + + test('escapes values used in attribute position', () => { + const url = 'x">'; + expect(String(html``)).toBe(''); + }); + + test('leaves the static parts of the template untouched', () => { + expect(String(html`

x

`)).toBe('

x

'); + }); + + test('renders nullish values as an empty string', () => { + expect(String(html`

${null}${undefined}

`)).toBe('

'); + }); + + test('does not double-escape nested fragments', () => { + const inner = html`${'a&b'}`; + expect(String(html`

${inner}

`)).toBe('

a&b

'); + }); + + // 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた + test('joins arrays without separators instead of stringifying them', () => { + const items = [html`
  • 1
  • `, html`
  • 2
  • `]; + expect(String(html`
      ${items}
    `)).toBe('
    • 1
    • 2
    '); + }); + + test('escapes array elements that are not fragments', () => { + expect(String(html`

    ${['', '']}

    `)).toBe('

    <a><b>

    '); + }); +}); + +describe('raw', () => { + test('embeds trusted markup without escaping', () => { + expect(String(html``)).toBe(''); + }); + + test('produces a Raw fragment', () => { + expect(raw('x')).toBeInstanceOf(Raw); + expect(html`x`).toBeInstanceOf(Raw); + }); +}); + +describe('joinHtml', () => { + test('joins fragments with the given separator', () => { + expect(String(joinHtml([html`
  • 1
  • `, html`
  • 2
  • `], '\n'))).toBe('
  • 1
  • \n
  • 2
  • '); + }); + + test('returns an empty fragment for an empty list', () => { + expect(String(joinHtml([], '\n'))).toBe(''); + }); +}); diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts new file mode 100644 index 0000000000..1d57f0d67d --- /dev/null +++ b/packages-private/diagnostics-shared/test/stats.test.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats'; + +describe('median', () => { + test('takes the middle value of an odd-length sample', () => { + expect(median([3, 1, 2])).toBe(2); + }); + + // 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため + test('rounds the average of an even-length sample', () => { + expect(median([1, 2])).toBe(2); + expect(median([1, 4])).toBe(3); + }); +}); + +describe('mad', () => { + test('measures the median absolute deviation', () => { + expect(mad([1, 1, 1])).toBe(0); + expect(mad([1, 2, 3])).toBe(1); + }); + + test('refuses to compute from a single sample', () => { + expect(() => mad([1])).toThrow(); + }); +}); + +describe('finiteMedian', () => { + test('ignores non-finite entries', () => { + expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2); + }); + + test('returns null by default when nothing is finite', () => { + expect(finiteMedian([null, undefined])).toBeNull(); + }); + + test('returns the supplied default when nothing is finite', () => { + expect(finiteMedian([null, undefined], 0)).toBe(0); + }); +}); + +describe('sampleSpread', () => { + test('needs at least two finite samples', () => { + expect(sampleSpread([1])).toBeNull(); + expect(sampleSpread([1, null])).toBeNull(); + expect(sampleSpread([1, 3])).toBe(1); + }); +}); + +describe('pairedDeltaSummary', () => { + const base = [ + { round: 1, value: 100 }, + { round: 2, value: 200 }, + { round: 3, value: 300 }, + ]; + + test('compares base and head within the same round', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 3, value: 320 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({ + median: 20, + mad: 10, + min: 10, + max: 30, + samples: 3, + }); + }); + + test('drops rounds that only one side has', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 9, value: 999 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2); + }); + + // 1サンプルでも中央値・最小・最大は定まる (偏差は常に0) + test('summarizes a single paired round without treating MAD as an error', () => { + expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({ + median: 30, + mad: 0, + min: 30, + max: 30, + samples: 1, + }); + }); + + test('fails loudly when no round is shared', () => { + expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/); + }); + + // 負のroundはwarmupを表すので集計に混ぜない + test('ignores warmup rounds', () => { + const warmupBase = [{ round: -1, value: 0 }, ...base]; + const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }]; + + expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3); + }); +}); diff --git a/packages-private/diagnostics-shared/tsconfig.json b/packages-private/diagnostics-shared/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 19151ecd88..26a43fb13c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,11 +18,11 @@ "build": "rolldown -c", "build:unit": "rolldown -c --sourcemap", "build:e2e": "rolldown -c --e2e", - "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json", + "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "pnpm compile-config && node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "pnpm compile-config && rolldown -c --watch", - "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit", + "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "test": "pnpm build:unit && cross-env NODE_ENV=test pnpm compile-config && vitest --config vitest.config.unit.ts", @@ -51,43 +51,43 @@ "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.1075.0", - "@aws-sdk/lib-storage": "3.1075.0", + "@aws-sdk/client-s3": "3.1085.0", + "@aws-sdk/lib-storage": "3.1085.0", "@fastify/accepts": "5.0.4", - "@fastify/cors": "11.2.0", + "@fastify/cors": "11.3.0", "@fastify/http-proxy": "11.5.0", - "@fastify/multipart": "10.0.0", - "@fastify/otel": "0.19.0", - "@fastify/static": "9.1.3", + "@fastify/multipart": "10.1.0", + "@fastify/otel": "0.20.1", + "@fastify/static": "10.1.0", "@kitajs/html": "4.2.13", "@misskey-dev/emoji-assets": "17.0.3", "@misskey-dev/emoji-data": "17.0.3", "@misskey-dev/sharp-read-bmp": "1.3.1", "@misskey-dev/summaly": "5.5.1", - "@napi-rs/canvas": "1.0.1", - "@nestjs/common": "11.1.27", - "@nestjs/core": "11.1.27", - "@nestjs/testing": "11.1.27", + "@napi-rs/canvas": "1.0.2", + "@nestjs/common": "11.1.28", + "@nestjs/core": "11.1.28", + "@nestjs/testing": "11.1.28", "@opentelemetry/api": "1.9.1", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", + "@opentelemetry/core": "2.9.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.220.0", "@opentelemetry/instrumentation-pg": "0.72.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1", - "@opentelemetry/sdk-trace-node": "2.7.1", - "@opentelemetry/semantic-conventions": "1.40.0", - "@oxc-project/runtime": "0.137.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace-base": "2.9.0", + "@opentelemetry/sdk-trace-node": "2.9.0", + "@opentelemetry/semantic-conventions": "1.43.0", + "@oxc-project/runtime": "0.139.0", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.62.0", - "@sentry/profiling-node": "10.62.0", + "@sentry/node": "10.65.0", + "@sentry/profiling-node": "10.65.0", "@simplewebauthn/server": "13.3.2", - "@smithy/node-http-handler": "4.8.2", + "@smithy/node-http-handler": "4.9.5", "accepts": "1.3.8", "ajv": "8.20.0", "archiver": "8.0.0", "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "bullmq": "5.79.2", + "bullmq": "5.80.2", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", "chalk-template": "1.1.2", @@ -96,12 +96,12 @@ "content-disposition": "2.0.1", "date-fns": "4.4.0", "deep-email-validator": "0.1.27", - "fastify": "5.9.0", + "fastify": "5.10.0", "fastify-raw-body": "5.0.0", - "feed": "5.2.1", + "feed": "6.0.0", "file-type": "22.0.1", "fluent-ffmpeg": "2.1.3", - "got": "15.0.7", + "got": "15.1.0", "hpagent": "1.2.0", "http-link-header": "1.1.3", "i18n": "workspace:*", @@ -112,17 +112,17 @@ "json5": "2.2.3", "jsonld": "9.0.0", "juice": "12.1.1", - "meilisearch": "0.58.0", + "meilisearch": "0.59.0", "mfm-js": "0.26.0", "mime-types": "3.0.2", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.202508261828", - "nanoid": "5.1.16", + "nanoid": "6.0.0", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "node-html-parser": "8.0.3", - "nodemailer": "9.0.1", + "node-html-parser": "9.0.0", + "nodemailer": "9.0.3", "os-utils": "0.0.14", "otpauth": "9.5.1", "pg": "8.22.0", @@ -132,21 +132,21 @@ "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.25.0", + "re2": "1.26.0", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.6", "secure-json-parse": "4.1.0", "semver": "7.8.5", - "sharp": "0.35.2", + "sharp": "0.35.3", "slacc": "0.1.5", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.31.11", + "systeminformation": "5.31.16", "tinycolor2": "1.6.0", "tmp": "0.2.7", - "tsc-alias": "1.8.17", + "tsc-alias": "1.9.0", "typeorm": "1.0.0", "ulid": "3.0.2", "vary": "1.1.2", @@ -156,9 +156,9 @@ }, "devDependencies": { "@kitajs/ts-html-plugin": "4.1.4", - "@nestjs/platform-express": "11.1.27", + "@nestjs/platform-express": "11.1.28", "@rollup/plugin-esm-shim": "0.1.8", - "@sentry/vue": "10.62.0", + "@sentry/vue": "10.65.0", "@sinonjs/fake-timers": "15.4.0", "@types/accepts": "1.3.7", "@types/archiver": "8.0.0", @@ -167,7 +167,7 @@ "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "26.0.1", + "@types/node": "26.1.1", "@types/nodemailer": "8.0.1", "@types/pg": "8.20.0", "@types/qrcode": "1.5.6", @@ -183,21 +183,21 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/coverage-v8": "4.1.9", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@vitest/coverage-v8": "4.1.10", "aws-sdk-client-mock": "4.1.0", "cbor2": "2.3.0", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", "execa": "9.6.1", "fkill": "10.0.3", - "js-yaml": "5.2.0", + "js-yaml": "5.2.1", "pid-port": "2.1.1", - "rolldown": "1.1.3", + "rolldown": "1.1.5", "simple-oauth2": "5.1.0", - "vite": "8.1.0", - "vitest": "4.1.9", + "vite": "8.1.4", + "vitest": "4.1.10", "vitest-mock-extended": "4.0.0" } } diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index e6a9888a09..768c3afc03 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -160,7 +160,7 @@ export default defineConfig((args) => { clearScreen: false, }, // ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする - external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules, + external: isWatchMode ? /^(?!@\/|\0)[^.\/](?!:[\/\\])/ : externalModules, }; } }); diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts deleted file mode 100644 index a95eb3075c..0000000000 --- a/packages/backend/scripts/measure-memory.mts +++ /dev/null @@ -1,303 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ChildProcess, fork } from 'node:child_process'; -import { dirname, join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import * as fs from 'node:fs/promises'; -import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; -import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts'; - -const backendDir = process.env.MK_MEMORY_BACKEND_DIR == null || process.env.MK_MEMORY_BACKEND_DIR === '' - ? join(import.meta.dirname, '..') - : resolve(process.env.MK_MEMORY_BACKEND_DIR); - -function readIntegerEnv(name, defaultValue, min) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -function readBooleanEnv(name, defaultValue) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (rawValue === '1' || rawValue === 'true') return true; - if (rawValue === '0' || rawValue === 'false') return false; - throw new Error(`${name} must be one of: 1, 0, true, false`); -} - -const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); -const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); -const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; - -const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; -const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; - -type GcMessage = 'gc ok' | 'gc unavailable'; -type RuntimeMemoryUsageMessage = { - type: 'memory usage'; - value: NodeJS.MemoryUsage; -}; -type HeapSnapshotMessage = { - type: 'heap snapshot'; - path?: string; -}; -type HeapSnapshotErrorMessage = { - type: 'heap snapshot error'; - message: string; -}; -type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; - -function parseMemoryFile(content: string, keys: KS, path: string): Record { - const result = {} as Record; - for (const _key of keys) { - const key = _key as KS[number]; - const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); - if (match) { - result[key] = parseInt(match[1], 10); - } else { - throw new Error(`Failed to parse ${key} from ${path}`); - } - } - return result; -} - -function bytesToKiB(value: number) { - return Math.round(value / 1024); -} - -async function getMemoryUsage(pid: number) { - const path = `/proc/${pid}/status`; - const status = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(status, procStatusKeys, path); -} - -async function getSmapsRollupMemoryUsage(pid: number) { - const path = `/proc/${pid}/smaps_rollup`; - const smapsRollup = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(smapsRollup, smapsRollupKeys, path); -} - -function isRecord(value: unknown): value is Record { - return value != null && typeof value === 'object'; -} - -function isGcMessage(message: unknown): message is GcMessage { - return message === 'gc ok' || message === 'gc unavailable'; -} - -function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { - return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); -} - -function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { - if (!isRecord(message)) return false; - if (message.type === 'heap snapshot') return true; - return message.type === 'heap snapshot error' && typeof message.message === 'string'; -} - -function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = globalThis.setTimeout(() => { - serverProcess.off('message', onMessage); - reject(new Error(`Timed out waiting for ${description}`)); - }, timeout); - - const onMessage = (message: unknown) => { - if (!predicate(message)) return; - globalThis.clearTimeout(timer); - serverProcess.off('message', onMessage); - resolve(message); - }; - - serverProcess.on('message', onMessage); - }); -} - -async function getRuntimeMemoryUsage(serverProcess: ChildProcess) { - const response = waitForMessage( - serverProcess, - isRuntimeMemoryUsageMessage, - 'memory usage', - ); - - serverProcess.send('memory usage'); - - const message = await response; - const memoryUsage = message.value; - - return { - HeapTotal: bytesToKiB(memoryUsage.heapTotal), - HeapUsed: bytesToKiB(memoryUsage.heapUsed), - External: bytesToKiB(memoryUsage.external), - ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), - }; -} - -async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { - if (!HEAP_SNAPSHOT) return null; - - const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); - const response = waitForMessage( - serverProcess, - isHeapSnapshotResponseMessage, - 'heap snapshot', - HEAP_SNAPSHOT_TIMEOUT, - ); - - serverProcess.send({ - type: 'heap snapshot', - path: snapshotPath, - }); - - const message = await response; - if (message.type === 'heap snapshot error') { - throw new Error(`Failed to write heap snapshot: ${message.message}`); - } - - const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath; - - try { - if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') { - await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true }); - await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH); - } - - const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); - return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }); - } finally { - await fs.unlink(writtenPath).catch(err => { - process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); - }); - } -} - -async function getAllMemoryUsage(serverProcess: ChildProcess) { - const pid = serverProcess.pid!; - const stableSmapsRollup = await measureMemoryUntilStable( - () => getSmapsRollupMemoryUsage(pid), - ); - - return { - memoryUsage: { - ...await getMemoryUsage(pid), - ...stableSmapsRollup.memoryUsage, - ...await getRuntimeMemoryUsage(serverProcess), - }, - stability: stableSmapsRollup.stability, - }; -} - -async function measureMemory() { - const serverProcess = fork(join(backendDir, 'built/entry.js'), [], { - cwd: backendDir, - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [...process.execArgv, '--expose-gc'], - }); - - const serverReady = waitForMessage( - serverProcess, - (message): message is 'ok' => message === 'ok', - 'server startup', - STARTUP_TIMEOUT, - ); - - serverProcess.stdout?.on('data', (data) => { - process.stderr.write(`[server stdout] ${data}`); - }); - - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(`[server stderr] ${data}`); - }); - - serverProcess.on('error', (err) => { - process.stderr.write(`[server error] ${err}\n`); - }); - - async function triggerGc() { - const ok = waitForMessage( - serverProcess, - isGcMessage, - 'GC completion', - ); - - serverProcess.send('gc'); - - const message = await ok; - if (message === 'gc unavailable') { - throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); - } - } - - const startupStartTime = Date.now(); - try { - await serverReady; - } catch (err) { - serverProcess.kill('SIGTERM'); - throw err; - } - - const startupTime = Date.now() - startupStartTime; - process.stderr.write(`Server started in ${startupTime}ms\n`); - - await triggerGc(); - - const afterGc = await getAllMemoryUsage(serverProcess); - process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`); - - const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess); - - const serverExited = new Promise(resolve => { - const timer = globalThis.setTimeout(() => { - serverProcess.kill('SIGKILL'); - resolve(); - }, 10000); - serverProcess.once('exit', () => { - globalThis.clearTimeout(timer); - resolve(); - }); - }); - serverProcess.kill('SIGTERM'); - await serverExited; - - return { - timestamp: new Date().toISOString(), - phases: { - afterGc: { - memoryUsage: afterGc.memoryUsage, - memoryStability: afterGc.stability, - heapSnapshot: heapSnapshotAfterGc, - }, - }, - }; -} - -export type MemorySample = Awaited>; - -async function main() { - console.log(JSON.stringify(await measureMemory(), null, 2)); -} - -main().catch((err) => { - console.error(JSON.stringify({ - error: err.message, - timestamp: new Date().toISOString(), - })); - process.exit(1); -}); diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs index 9d608b233c..a0ccea3b16 100644 --- a/packages/backend/scripts/watch.mjs +++ b/packages/backend/scripts/watch.mjs @@ -21,7 +21,7 @@ import { execa } from 'execa'; }); }, 3000); - execa('tsgo', ['-w', '-p', 'tsconfig.json'], { + execa('tsc', ['-w', '-p', 'tsconfig.json'], { stdout: process.stdout, stderr: process.stderr, }); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8ea1630d4d..05e9854184 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -520,6 +520,11 @@ export class NoteCreateService implements OnApplicationShutdown { // specified / direct noteはreject throw new Error('Renote target is not public or home'); } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } } // Check blocking @@ -534,19 +539,35 @@ export class NoteCreateService implements OnApplicationShutdown { } } - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } + if (data.reply) { + switch (data.reply.visibility) { + case 'public': + // public noteは無条件にreply可能 + break; + case 'home': + // home noteはhome以下にreply可能 + if (data.visibility === 'public') { + data.visibility = 'home'; + } + break; + case 'followers': + // followers noteはfollowers以下にreply可能 + if (data.visibility === 'public' || data.visibility === 'home') { + data.visibility = 'followers'; + } + break; + case 'specified': + // specified / direct noteはspecifiedのみreply可能 + if (data.visibility !== 'specified') { + data.visibility = 'specified'; + } + break; + } - // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; + // ローカルのみにリプライしたらローカルのみにする + if (data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } } if (data.text) { diff --git a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts index ce98d8b0cf..c3d5064a6b 100644 --- a/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts +++ b/packages/backend/src/core/telemetry/adapters/OpenTelemetryAdapter.ts @@ -11,6 +11,7 @@ import { installHttpClientInstrumentation } from '@/core/telemetry/http-client-i import { installDatabaseInstrumentation } from '@/core/telemetry/database-instrumentation.js'; import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js'; import { executeSpan, getQueueTraceContextMode, injectActiveTraceContext, recordSpanError, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js'; +import type { LogTraceContext } from '@/logging/types.js'; import type { Span, SpanStatusCode, Tracer } from '@opentelemetry/api'; import type { Resource, ResourceDetector } from '@opentelemetry/resources'; import type { ParentBasedSampler, Sampler } from '@opentelemetry/sdk-trace-base'; @@ -161,6 +162,15 @@ export class OpenTelemetryAdapter implements TelemetryAdapter { }); } + /** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */ + public getActiveTraceContext(): LogTraceContext | undefined { + const activeSpan = this.deps.getActiveSpan(); + if (activeSpan == null) return undefined; + + const { traceId, spanId, traceFlags } = activeSpan.spanContext(); + return { traceId, spanId, traceFlags }; + } + public startSpan(name: string, fn: () => T): T { // 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。 return this.deps.tracer.startActiveSpan(name, span => executeSpan(span, fn, this.deps.spanStatusCodeError)); diff --git a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts index 8b9674af6d..fd65f414f6 100644 --- a/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts +++ b/packages/backend/src/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -6,6 +6,7 @@ import Logger from '@/logger.js'; import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js'; import { getQueueTraceContextMode, injectActiveTraceContext, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js'; +import type { LogTraceContext } from '@/logging/types.js'; import type * as SentryNode from '@sentry/node'; import type { NodeOptions } from '@sentry/node'; import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js'; @@ -175,6 +176,15 @@ export class SentryTelemetryAdapter implements TelemetryAdapter { }); } + /** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */ + public getActiveTraceContext(): LogTraceContext | undefined { + const activeSpan = this.Sentry.getActiveSpan(); + if (activeSpan == null) return undefined; + + const { traceId, spanId, traceFlags } = activeSpan.spanContext(); + return { traceId, spanId, traceFlags }; + } + public startSpan(name: string, fn: () => T): T { return this.Sentry.startSpan({ name }, fn); } diff --git a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts index f9564e7e7e..29cbb61f4c 100644 --- a/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts +++ b/packages/backend/src/core/telemetry/adapters/TelemetryAdapter.ts @@ -4,6 +4,7 @@ */ import type { Config } from '@/config.js'; +import type { LogTraceContext } from '@/logging/types.js'; import type { QueueTraceContextCarrier } from '../queue-trace-context.js'; export type SentryBackendConfig = NonNullable; @@ -35,6 +36,9 @@ export interface TelemetryAdapter { */ captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void; + /** 現在のactive Spanからログへ付加するTrace Contextを取得する。 */ + getActiveTraceContext?(): LogTraceContext | undefined; + /** * API endpointやqueue jobなど、呼び出し側の処理単位をspanで包む。 * fnの戻り値・例外はそのまま呼び出し側へ返し、Promiseの場合はsettleまでspanを閉じない。 diff --git a/packages/backend/src/core/telemetry/telemetry-registry.ts b/packages/backend/src/core/telemetry/telemetry-registry.ts index b08301816b..94e26e21d8 100644 --- a/packages/backend/src/core/telemetry/telemetry-registry.ts +++ b/packages/backend/src/core/telemetry/telemetry-registry.ts @@ -4,6 +4,7 @@ */ import type { Config } from '@/config.js'; +import { setLogTraceContextProvider } from '@/logging/logging-runtime.js'; import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js'; import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js'; import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js'; @@ -23,14 +24,21 @@ export async function initTelemetry(config: Config): Promise { }; // SentryとOTelを同時に使う場合はproviderを分けず、Sentry側へOTLP processorを追加する。 + let adapter: TelemetryAdapter | undefined; if (config.sentryForBackend && otelForBackend) { - adapters.push(await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend)); + adapter = await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend); } else if (config.sentryForBackend) { // Sentry単体時は既存のSentry adapterだけを登録する。 - adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend)); + adapter = await SentryTelemetryAdapter.create(config.sentryForBackend); } else if (otelForBackend) { // OTel単体時だけMisskey自身でNodeTracerProviderを立てる。 - adapters.push(await OpenTelemetryAdapter.create(otelForBackend)); + adapter = await OpenTelemetryAdapter.create(otelForBackend); + } + + if (adapter != null) { + adapters.push(adapter); + // Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。 + setLogTraceContextProvider(() => adapter.getActiveTraceContext?.()); } } diff --git a/packages/backend/src/logging/JsonConsoleBackend.ts b/packages/backend/src/logging/JsonConsoleBackend.ts index a13f6c262e..664ffd882d 100644 --- a/packages/backend/src/logging/JsonConsoleBackend.ts +++ b/packages/backend/src/logging/JsonConsoleBackend.ts @@ -23,6 +23,9 @@ type JsonLogRecord = { readonly processId: number; readonly isPrimary: boolean; readonly workerId: number | null; + readonly trace_id?: string; + readonly span_id?: string; + readonly trace_flags?: number; }; const defaultDependencies: JsonConsoleBackendDependencies = { @@ -45,6 +48,10 @@ function createJsonLogRecord(record: LogRecord): JsonLogRecord { processId: record.processId, isPrimary: record.isPrimary, workerId: record.workerId, + // active Spanの情報は値が存在するログだけ、標準のsnake_case名で出力します。 + ...(record.traceId != null ? { trace_id: record.traceId } : {}), + ...(record.spanId != null ? { span_id: record.spanId } : {}), + ...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}), }; } diff --git a/packages/backend/src/logging/LogManager.ts b/packages/backend/src/logging/LogManager.ts index 565bc5e0b4..b4381dd084 100644 --- a/packages/backend/src/logging/LogManager.ts +++ b/packages/backend/src/logging/LogManager.ts @@ -13,7 +13,7 @@ import { type LogNormalizationProfile, } from './LogNormalizer.js'; import type { LogBackend } from './LogBackend.js'; -import type { LogLevel, LogLevelSetting, LogRecord, LogRecordInput } from './types.js'; +import type { LogLevel, LogLevelSetting, LogRecord, LogRecordInput, LogTraceContextProvider } from './types.js'; /** ログを出力したプロセスを識別するための情報です。 */ export type LogProcessInfo = { @@ -113,6 +113,7 @@ export class LogManager { private backend: LogBackend; private readonly dependencies: LogManagerDependencies; private normalizationProfile: LogNormalizationProfile; + private traceContextProvider: LogTraceContextProvider | undefined; private configuredLevel: LogLevelSetting | undefined; private configuredDomains: readonly (readonly [string, LogLevelSetting])[]; private shutdownPromise: Promise | undefined; @@ -132,6 +133,7 @@ export class LogManager { ...dependencies, }; this.normalizationProfile = options.normalizationProfile ?? 'standard'; + this.traceContextProvider = undefined; this.configuredLevel = undefined; this.configuredDomains = []; } @@ -156,6 +158,11 @@ export class LogManager { this.normalizationProfile = profile; } + /** ログ出力時にactiveなTrace Contextを取得する処理を登録します。 */ + public setTraceContextProvider(provider?: LogTraceContextProvider): void { + this.traceContextProvider = provider; + } + /** backendに残っているログをflushしてから終了処理を行います。 */ public shutdown(): Promise { if (this.shutdownPromise != null) return this.shutdownPromise; @@ -220,6 +227,8 @@ export class LogManager { const normalizedError = typeof error !== 'undefined' ? serializeLogError(error, { profile: this.normalizationProfile }) : undefined; + // 実際に出力するログだけ、TelemetryからactiveなTrace Contextを取得します。 + const traceContext = this.traceContextProvider?.(); const record = { ...inputWithoutStructuredValues, context, @@ -228,6 +237,7 @@ export class LogManager { processId: processInfo.processId, isPrimary: processInfo.isPrimary, workerId: processInfo.workerId, + ...(traceContext ?? {}), ...(normalizedAttributes ? { attributes: normalizedAttributes } : {}), ...(normalizedError ? { error: normalizedError } : {}), } as LogRecord; diff --git a/packages/backend/src/logging/logging-runtime.ts b/packages/backend/src/logging/logging-runtime.ts index 3d842b31c7..bf933c8533 100644 --- a/packages/backend/src/logging/logging-runtime.ts +++ b/packages/backend/src/logging/logging-runtime.ts @@ -8,8 +8,8 @@ import { BootstrapConsoleBackend } from './BootstrapConsoleBackend.js'; import { JsonConsoleBackend } from './JsonConsoleBackend.js'; import { PrettyConsoleBackend } from './PrettyConsoleBackend.js'; import type { LogManagerConfiguration } from './LogManager.js'; +import type { LogTraceContextProvider, LogFormat } from './types.js'; import type { LogBackend } from './LogBackend.js'; -import type { LogFormat } from './types.js'; /** * プロセス内のすべてのLoggerが共有するLogManagerです。 @@ -32,6 +32,11 @@ export function configureLogging(configuration?: LogManagerConfiguration & { rea logManager.setBackend(backend); } +/** Telemetry初期化後に、ログへTrace Contextを付加する取得処理を登録します。 */ +export function setLogTraceContextProvider(provider?: LogTraceContextProvider): void { + logManager.setTraceContextProvider(provider); +} + /** プロセス終了前に現在のログ出力処理を保留分まで書き出して閉じます。 */ export function shutdownLogging(): Promise { return logManager.shutdown(); diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts index 1861681840..bab86e5b04 100644 --- a/packages/backend/src/logging/types.ts +++ b/packages/backend/src/logging/types.ts @@ -26,6 +26,16 @@ export type LogAttributeValue = /** 正規化後のログ属性です。 */ export type LogAttributes = Readonly>; +/** activeなSpanとログを関連付けるためのTrace Contextです。 */ +export type LogTraceContext = { + readonly traceId: string; + readonly spanId: string; + readonly traceFlags: number; +}; + +/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */ +export type LogTraceContextProvider = () => LogTraceContext | undefined; + /** ロガーの呼び出し側が構造化ログとして指定する入力です。 */ export type LogWriteInput = { readonly level: LogLevel; @@ -81,6 +91,9 @@ export type LogRecord = Omit & { readonly processId: number; readonly isPrimary: boolean; readonly workerId: number | null; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: number; readonly attributes?: LogAttributes; readonly error?: SerializedError; }; diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index c20c39f15a..631fc64e6e 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -11,6 +11,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { runQueueJobWithTraceContext } from './queue-job-runner.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -176,26 +177,30 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for system`); } }; + const logger = this.logger.createSubLogger('system'); this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: System: ' + job.name, job.data, () => processer(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: System: ' + job.name, + job.data, + () => processer(job) as Promise, + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: System: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); - const logger = this.logger.createSubLogger('system'); - this.systemQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err: Error) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -227,26 +232,30 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for db`); } }; + const logger = this.logger.createSubLogger('db'); this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: DB: ' + job.name, job.data, () => processer(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: DB: ' + job.name, + job.data, + () => processer(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: DB: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); - const logger = this.logger.createSubLogger('db'); - this.dbQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -254,8 +263,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { + const logger = this.logger.createSubLogger('deliver'); + this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: Deliver', job.data, () => this.deliverProcessorService.process(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: Deliver', + job.data, + () => this.deliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, @@ -269,18 +292,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('deliver'); - this.deliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -288,8 +302,23 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { + const logger = this.logger.createSubLogger('inbox'); + this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: Inbox', job.data, () => this.inboxProcessorService.process(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: Inbox', + job.data, + () => this.inboxProcessorService.process(job), + err => { + const activityId = job.data.activity ? job.data.activity.id : 'none'; + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${activityId}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, @@ -303,18 +332,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('inbox'); - this.inboxQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -322,8 +342,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { + const logger = this.logger.createSubLogger('user-webhook'); + this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: UserWebhookDeliver', job.data, () => this.userWebhookDeliverProcessorService.process(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: UserWebhookDeliver', + job.data, + () => this.userWebhookDeliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, @@ -337,18 +371,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('user-webhook'); - this.userWebhookDeliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -356,8 +381,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { + const logger = this.logger.createSubLogger('system-webhook'); + this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: SystemWebhookDeliver', job.data, () => this.systemWebhookDeliverProcessorService.process(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: SystemWebhookDeliver', + job.data, + () => this.systemWebhookDeliverProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, @@ -371,18 +410,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('system-webhook'); - this.systemWebhookDeliverQueueWorker .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -399,9 +429,22 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for relationship`); } }; + const logger = this.logger.createSubLogger('relationship'); this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: Relationship: ' + job.name, job.data, () => processer(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: Relationship: ' + job.name, + job.data, + () => processer(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: Relationship: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, @@ -412,18 +455,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const logger = this.logger.createSubLogger('relationship'); - this.relationshipQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -438,27 +472,31 @@ export class QueueProcessorService implements OnApplicationShutdown { default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); } }; + const logger = this.logger.createSubLogger('objectStorage'); this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: ObjectStorage: ' + job.name, job.data, () => processer(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: ObjectStorage: ' + job.name, + job.data, + () => processer(job) as Promise, + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job.name}: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); - const logger = this.logger.createSubLogger('objectStorage'); - this.objectStorageQueueWorker .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => { - logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { - level: 'error', - extra: { job, err }, - }); - }) .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } @@ -466,8 +504,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { + const logger = this.logger.createSubLogger('ended-poll-notification'); + this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: EndedPollNotification', job.data, () => this.endedPollNotificationProcessorService.process(job)); + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: EndedPollNotification', + job.data, + () => this.endedPollNotificationProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: EndedPollNotification: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, @@ -477,8 +529,22 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region post scheduled note { - this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { - return this.telemetryService.startSpanWithTraceContext('Queue: PostScheduledNote', job.data, () => this.postScheduledNoteProcessorService.process(job)); + const logger = this.logger.createSubLogger('post-scheduled-note'); + + this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => { + return runQueueJobWithTraceContext( + this.telemetryService, + 'Queue: PostScheduledNote', + job.data, + () => this.postScheduledNoteProcessorService.process(job), + err => { + logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) }); + this.telemetryService.captureMessage(`Queue: PostScheduledNote: ${err.name}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + }, + ); }, { ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE), autorun: false, diff --git a/packages/backend/src/queue/queue-job-runner.ts b/packages/backend/src/queue/queue-job-runner.ts new file mode 100644 index 0000000000..aa1d29c512 --- /dev/null +++ b/packages/backend/src/queue/queue-job-runner.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { TelemetryService } from '@/core/telemetry/TelemetryService.js'; + +type QueueTelemetryService = Pick; + +/** QueueのprocessorをTrace Context付きで実行し、失敗処理をSpan内で行います。 */ +export function runQueueJobWithTraceContext( + telemetryService: QueueTelemetryService, + spanName: string, + jobData: object, + processJob: () => T | Promise, + onError: (error: Error) => void, +): Promise { + return telemetryService.startSpanWithTraceContext(spanName, jobData, async (): Promise => { + try { + return await processJob(); + } catch (error) { + // 失敗イベントを待たず、processor Spanがactiveな間にログと通知を行います。 + const normalizedError = error instanceof Error ? error : new Error(String(error)); + try { + onError(normalizedError); + } catch { + // 失敗ログの処理が例外を投げても、Queueへは元のエラーを返します。 + } + throw error; + } + }); +} diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 685c8a8ab3..43cba86e3d 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -199,51 +199,54 @@ export class ApiCallService implements OnApplicationShutdown { reply.send(); return; } - const [path, cleanup] = await createTemp(); - await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - // ファイルサイズが制限を超えていた場合 - // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある - if (multipartData.file.truncated) { - cleanup(); - reply.code(413); - reply.send(); - return; - } + try { + await stream.pipeline(multipartData.file, fs.createWriteStream(path)); - const fields = {} as Record; - for (const [k, v] of Object.entries(multipartData.fields)) { - fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; - } - - // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) - const token = request.headers.authorization?.startsWith('Bearer ') - ? request.headers.authorization.slice(7) - : fields['i']; - if (token != null && typeof token !== 'string') { - reply.code(400); - return; - } - - return await this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { - const call = this.call(endpoint, user, app, fields, { - name: multipartData.filename, - path: path, - }, request).then((res) => { - this.send(reply, res); - }).catch((err: ApiError) => { - this.#sendApiError(reply, err); - }); - - if (user) { - this.logIp(request, user); + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + reply.code(413); + reply.send(); + return; } - return call; - }).catch(err => { - this.#sendAuthenticationError(reply, err); - })); + const fields = {} as Record; + for (const [k, v] of Object.entries(multipartData.fields)) { + fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; + } + + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : fields['i']; + if (token != null && typeof token !== 'string') { + reply.code(400); + return; + } + + return await this.telemetryService.startSpan('API: ' + endpoint.name, () => this.authenticateService.authenticate(token).then(([user, app]) => { + const call = this.call(endpoint, user, app, fields, { + name: multipartData.filename, + path: path, + }, request).then((res) => { + this.send(reply, res); + }).catch((err: ApiError) => { + this.#sendApiError(reply, err); + }); + + if (user) { + this.logIp(request, user); + } + + return call; + }).catch(err => { + this.#sendAuthenticationError(reply, err); + })); + } finally { + cleanup(); + } } @bindThis diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 53e297f867..8bc386c00c 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -139,7 +139,7 @@ services: bash -c " npm install -g pnpm pnpm -F backend i --frozen-lockfile - pnpm exec tsgo -p ./packages/backend/test-federation + pnpm exec tsc -p ./packages/backend/test-federation node ./packages/backend/test-federation/built/daemon.js " diff --git a/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts index 4b615179f4..ad6a559250 100644 --- a/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts +++ b/packages/backend/test/unit/core/telemetry/adapters/OpenTelemetryAdapter.ts @@ -153,6 +153,28 @@ describe('OpenTelemetryAdapter', () => { expect(span.end).toHaveBeenCalledTimes(1); }); + test('returns the active span context for log enrichment', () => { + const adapter = new OpenTelemetryAdapter({ + tracer: { startActiveSpan: vi.fn() }, + provider: { shutdown: vi.fn() }, + getActiveSpan: () => ({ + spanContext: () => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }), + } as any), + spanStatusCodeError: SpanStatusCode.ERROR, + shutdownTimeout: 10, + }); + + expect(adapter.getActiveTraceContext()).toEqual({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }); + }); + test('bridges captureMessage to the active span when one exists', () => { const activeSpan = { recordException: vi.fn(), diff --git a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts index c304731987..9423aebe51 100644 --- a/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts +++ b/packages/backend/test/unit/core/telemetry/adapters/SentryTelemetryAdapter.ts @@ -161,6 +161,40 @@ describe('SentryTelemetryAdapter', () => { }); }); +describe('SentryTelemetryAdapter trace context', () => { + test('returns the active span context for log enrichment', async () => { + const activeSpan = { + spanContext: () => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }), + }; + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + close: vi.fn(), + getActiveSpan: vi.fn(() => activeSpan), + })); + vi.doMock('@sentry/profiling-node', () => ({ + nodeProfilingIntegration: vi.fn(), + })); + + const adapter = await SentryTelemetryAdapter.create({ + enableNodeProfiling: false, + options: {}, + }); + + expect(adapter.getActiveTraceContext()).toEqual({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }); + + vi.doUnmock('@sentry/node'); + vi.doUnmock('@sentry/profiling-node'); + }); +}); + describe('SentryTelemetryAdapter.shutdown', () => { test('bounds Sentry.close() with a timeout so a stuck transport cannot hang process shutdown', async () => { const close = vi.fn().mockResolvedValue(true); diff --git a/packages/backend/test/unit/logging/JsonConsoleBackend.ts b/packages/backend/test/unit/logging/JsonConsoleBackend.ts index b7204bdd64..c9cbf998cd 100644 --- a/packages/backend/test/unit/logging/JsonConsoleBackend.ts +++ b/packages/backend/test/unit/logging/JsonConsoleBackend.ts @@ -62,6 +62,23 @@ describe('JsonConsoleBackend', () => { }); }); + test('writes trace context using the standard JSON field names', () => { + const output = vi.fn<(line: string) => void>(); + const backend = new JsonConsoleBackend({ output }); + + backend.write(createRecord({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + })); + + expect(JSON.parse(output.mock.calls[0][0])).toMatchObject({ + trace_id: '0123456789abcdef0123456789abcdef', + span_id: '0123456789abcdef', + trace_flags: 0, + }); + }); + test('escapes newlines so each record remains one physical line', () => { const output = vi.fn<(line: string) => void>(); const backend = new JsonConsoleBackend({ output }); diff --git a/packages/backend/test/unit/logging/LogManager.ts b/packages/backend/test/unit/logging/LogManager.ts index eac853afdb..5695b34837 100644 --- a/packages/backend/test/unit/logging/LogManager.ts +++ b/packages/backend/test/unit/logging/LogManager.ts @@ -5,7 +5,7 @@ import { describe, expect, test, vi } from 'vitest'; import type { LogBackend } from '@/logging/LogBackend.js'; -import type { LogRecordInput } from '@/logging/types.js'; +import type { LogRecordInput, LogTraceContext } from '@/logging/types.js'; import { LogManager } from '@/logging/LogManager.js'; /** テストで使う最小構成のログ入力を作成します。 */ @@ -87,6 +87,39 @@ describe('LogManager', () => { }); }); + test('adds active trace context to the record after filtering', () => { + const { manager, write } = createManager(); + const traceContext: LogTraceContext = { + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }; + const provider = vi.fn(() => traceContext); + manager.setTraceContextProvider(provider); + + manager.write(createInput()); + + expect(provider).toHaveBeenCalledOnce(); + expect(write.mock.calls[0][0]).toMatchObject(traceContext); + }); + + test('does not get trace context for logs that will not be written', () => { + const provider = vi.fn(() => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 1, + })); + const filtered = createManager({ configuration: { level: 'warn' } }); + filtered.manager.setTraceContextProvider(provider); + filtered.manager.write(createInput('info')); + + const quiet = createManager({ quiet: true }); + quiet.manager.setTraceContextProvider(provider); + quiet.manager.write(createInput('error')); + + expect(provider).not.toHaveBeenCalled(); + }); + test('does not call the backend in quiet mode', () => { const { manager, write } = createManager({ quiet: true, verbose: true }); diff --git a/packages/backend/test/unit/queue/queue-job-runner.ts b/packages/backend/test/unit/queue/queue-job-runner.ts new file mode 100644 index 0000000000..3a587b0623 --- /dev/null +++ b/packages/backend/test/unit/queue/queue-job-runner.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, vi } from 'vitest'; +import { runQueueJobWithTraceContext } from '@/queue/queue-job-runner.js'; +import { TelemetryService } from '@/core/telemetry/TelemetryService.js'; + +describe('runQueueJobWithTraceContext', () => { + test('returns the processor result without invoking the error handler', async () => { + let spanActive = false; + const startSpanWithTraceContext = vi.fn((_name: string, _jobData: object, fn: () => T): T => { + spanActive = true; + const result = fn(); + if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; + spanActive = false; + return result; + }); + const telemetryService = { + startSpanWithTraceContext, + } as unknown as TelemetryService; + const onError = vi.fn(); + + await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, () => 'ok', onError)).resolves.toBe('ok'); + + expect(onError).not.toHaveBeenCalled(); + expect(spanActive).toBe(false); + }); + + test('handles failures while the processor span is active and rethrows the original error', async () => { + let spanActive = false; + const startSpanWithTraceContext = vi.fn((_name: string, _jobData: object, fn: () => T): T => { + spanActive = true; + const result = fn(); + if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T; + spanActive = false; + return result; + }); + const telemetryService = { + startSpanWithTraceContext, + } as unknown as TelemetryService; + const onError = vi.fn((error: Error) => { + expect(spanActive).toBe(true); + expect(error).toBeInstanceOf(Error); + }); + const originalError = new Error('failed'); + + await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, async () => { + throw originalError; + }, onError)).rejects.toBe(originalError); + + expect(onError).toHaveBeenCalledOnce(); + expect(spanActive).toBe(false); + }); +}); diff --git a/packages/backend/test/unit/telemetry-registry.ts b/packages/backend/test/unit/telemetry-registry.ts index fc672a3f7d..dbf10c9176 100644 --- a/packages/backend/test/unit/telemetry-registry.ts +++ b/packages/backend/test/unit/telemetry-registry.ts @@ -11,9 +11,14 @@ const mocks = vi.hoisted(() => { sentryCreate: vi.fn(), sentryCreateWithOtlpExport: vi.fn(), otelCreate: vi.fn(), + setLogTraceContextProvider: vi.fn(), }; }); +vi.mock('@/logging/logging-runtime.js', () => ({ + setLogTraceContextProvider: mocks.setLogTraceContextProvider, +})); + vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({ SentryTelemetryAdapter: { create: mocks.sentryCreate, @@ -40,6 +45,7 @@ describe('telemetry-registry', () => { mocks.sentryCreate.mockReset(); mocks.sentryCreateWithOtlpExport.mockReset(); mocks.otelCreate.mockReset(); + mocks.setLogTraceContextProvider.mockReset(); mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() }); mocks.sentryCreateWithOtlpExport.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() }); mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() }); @@ -59,6 +65,32 @@ describe('telemetry-registry', () => { expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled(); }); + test('registers the adapter trace context provider after telemetry initialization', async () => { + const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); + const getActiveTraceContext = vi.fn(() => ({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + })); + mocks.otelCreate.mockResolvedValue({ + shutdown: vi.fn(), + captureMessage: vi.fn(), + startSpan: vi.fn(), + getActiveTraceContext, + }); + + await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } })); + + expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function)); + const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown; + expect(provider()).toEqual({ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: 0, + }); + expect(getActiveTraceContext).toHaveBeenCalledOnce(); + }); + test('adds OTLP export to the Sentry provider when both Sentry and OTel are configured', async () => { const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js'); const sentryForBackend = { options: {}, enableNodeProfiling: false }; diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index ede45c1fcf..047f8b4e73 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "exports": { @@ -11,16 +11,16 @@ }, "devDependencies": { "@types/estree": "1.0.9", - "@types/node": "26.0.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", + "@types/node": "26.1.1", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", "rollup": "4.62.2" }, "dependencies": { "i18n": "workspace:*", "magic-string": "0.30.21", "oxc-walker": "1.0.0", - "rolldown": "1.1.3", - "vite": "8.1.0" + "rolldown": "1.1.5", + "vite": "8.1.4" } } diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index d11a22baf0..5c07e795f3 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -18,7 +18,7 @@ "mfm-js": "0.26.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "shiki": "4.3.0", + "shiki": "4.3.1", "tinycolor2": "1.6.0", "uuid": "14.0.1", "vue": "3.5.39" @@ -30,13 +30,13 @@ "@rollup/pluginutils": "5.4.0", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", - "@types/node": "26.0.1", + "@types/node": "26.1.1", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/coverage-v8": "4.1.9", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@vitest/coverage-v8": "4.1.10", "@vitejs/plugin-vue": "6.0.7", "@vue/runtime-core": "3.5.39", "eslint-plugin-import": "2.32.0", @@ -44,10 +44,11 @@ "intersection-observer": "0.12.2", "lightningcss": "1.32.0", "sass-embedded": "1.100.0", - "tsx": "4.22.4", - "vite": "8.1.0", - "vue-component-type-helpers": "3.3.5", + "tsx": "4.23.0", + "typescript": "6.0.2", + "vite": "8.1.4", + "vue-component-type-helpers": "3.3.7", "vue-eslint-parser": "10.4.1", - "vue-tsc": "3.3.5" + "vue-tsc": "3.3.7" } } diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index 8c14cc2c1d..24ba6bddb2 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -4,14 +4,14 @@ "private": true, "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "26.0.1", + "@types/node": "26.1.1", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", "eslint-plugin-vue": "10.9.2", "vue-eslint-parser": "10.4.1" }, @@ -23,7 +23,7 @@ "i18n": "workspace:*", "json5": "2.2.3", "misskey-js": "workspace:*", - "shiki": "4.3.0", + "shiki": "4.3.1", "tinycolor2": "1.6.0", "vue": "3.5.39" } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a8ec73fd25..0ccb18be2b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -6,7 +6,7 @@ "watch": "vite", "build": "tsx build.ts", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", - "build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", + "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", "chromatic": "chromatic", "test": "vitest --run --globals --config vitest.config.unit.ts", @@ -22,7 +22,7 @@ "@mcaptcha/core-glue": "0.1.0-alpha-5", "@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/emoji-data": "17.0.3", - "@sentry/vue": "10.62.0", + "@sentry/vue": "10.65.0", "@simplewebauthn/browser": "13.3.0", "@syuilo/aiscript": "1.2.1", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", @@ -34,7 +34,7 @@ "canvas-confetti": "1.9.4", "chart.js": "4.5.1", "chartjs-adapter-date-fns": "3.0.0", - "chartjs-chart-matrix": "3.0.4", + "chartjs-chart-matrix": "3.0.5", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", "compare-versions": "6.1.1", @@ -45,13 +45,13 @@ "frontend-shared": "workspace:*", "i18n": "workspace:*", "icons-subsetter": "workspace:*", - "idb-keyval": "6.2.5", + "idb-keyval": "6.3.0", "insert-text-at-cursor": "0.3.0", "ios-haptics": "0.1.5", "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.20.0", - "mediabunny": "1.49.0", + "mediabunny": "1.50.8", "mfm-js": "0.26.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", @@ -59,9 +59,9 @@ "punycode.js": "2.3.1", "qr-code-styling": "1.9.2", "qr-scanner": "1.4.2", - "sanitize-html": "2.17.5", + "sanitize-html": "2.17.6", "seedrandom": "3.0.5", - "shiki": "4.3.0", + "shiki": "4.3.1", "textarea-caret": "3.1.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", @@ -77,7 +77,7 @@ "@rollup/pluginutils": "5.4.0", "@storybook/addon-essentials": "8.6.18", "@storybook/addon-interactions": "8.6.18", - "@storybook/addon-links": "10.4.6", + "@storybook/addon-links": "10.5.0", "@storybook/addon-mdx-gfm": "8.6.18", "@storybook/addon-storysource": "8.6.18", "@storybook/blocks": "8.6.18", @@ -88,8 +88,8 @@ "@storybook/test": "8.6.18", "@storybook/theming": "8.6.18", "@storybook/types": "8.6.18", - "@storybook/vue3": "10.4.6", - "@storybook/vue3-vite": "10.4.6", + "@storybook/vue3": "10.5.0", + "@storybook/vue3-vite": "10.5.0", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", @@ -97,20 +97,20 @@ "@types/insert-text-at-cursor": "0.3.2", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "26.0.1", + "@types/node": "26.1.1", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.1", "@types/seedrandom": "3.0.8", "@types/textarea-caret": "3.0.4", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/coverage-v8": "4.1.9", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/coverage-v8": "4.1.10", "@vue/compiler-core": "3.5.39", "astring": "1.9.0", - "chromatic": "17.7.2", + "chromatic": "18.0.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "10.9.2", "execa": "9.6.1", @@ -119,27 +119,28 @@ "lightningcss": "1.32.0", "micromatch": "4.0.8", "minimatch": "10.2.5", - "msw": "2.14.6", + "msw": "2.15.0", "msw-storybook-addon": "2.0.7", "nodemon": "3.1.14", "oxc-walker": "1.0.0", "playwright": "1.61.1", - "prettier": "3.9.1", + "prettier": "3.9.5", "react": "19.2.7", "react-dom": "19.2.7", - "rolldown": "1.1.3", + "rolldown": "1.1.5", "rollup-plugin-visualizer": "7.0.1", "sass-embedded": "1.100.0", - "storybook": "10.4.6", + "storybook": "10.5.0", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", - "tsx": "4.22.4", - "vite": "8.1.0", + "tsx": "4.23.0", + "typescript": "6.0.2", + "vite": "8.1.4", "vite-plugin-glsl": "1.6.0", "vite-plugin-turbosnap": "1.0.3", - "vitest": "4.1.9", + "vitest": "4.1.10", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "3.3.5", + "vue-component-type-helpers": "3.3.7", "vue-eslint-parser": "10.4.1", - "vue-tsc": "3.3.5" + "vue-tsc": "3.3.7" } } diff --git a/packages/frontend/src/components/MkLightbox.item.vue b/packages/frontend/src/components/MkLightbox.item.vue index eb7f581019..5870652d25 100644 --- a/packages/frontend/src/components/MkLightbox.item.vue +++ b/packages/frontend/src/components/MkLightbox.item.vue @@ -127,7 +127,6 @@ SPDX-License-Identifier: AGPL-3.0-only