perf: diagnoistics improvements

This commit is contained in:
2026-06-12 01:22:01 +02:00
parent 29032b5a36
commit dac5cb42a5
29 changed files with 1168 additions and 28 deletions

View File

@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
pid: number;
type: string;
workingSetKb: number | null;
peakWorkingSetKb: number | null;
privateBytesKb: number | null;
creationTime: number | null;
cpuPercent: number | null;
}
export interface AppMetricsSnapshot {
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
processes: app.getAppMetrics().map((metric) => ({
pid: metric.pid,
type: metric.type,
workingSetKb: metric.memory?.workingSetSize ?? null
workingSetKb: metric.memory?.workingSetSize ?? null,
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
creationTime: metric.creationTime ?? null,
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
: null
}))
};
}
function readOptionalKilobytes(value: number | undefined): number | null {
return typeof value === 'number' && value >= 0 ? value : null;
}

View File

@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
describe('isPerfDiagEnabled', () => {
it('returns false when the flag is unset', () => {
expect(isPerfDiagEnabled({}, false)).toBe(false);
expect(isPerfDiagEnabled({}, true)).toBe(false);
expect(isPerfDiagEnabled({}, true)).toBe(true);
});
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
});
it('returns false in packaged builds unless force is set', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
expect(isPerfDiagEnabled({
METOYOU_PERF_DIAG: '1',
METOYOU_PERF_DIAG_FORCE: '1'
}, true)).toBe(true);
it('returns true in packaged Electron builds without env flags', () => {
expect(isPerfDiagEnabled({}, true)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
});
it('returns false in development when the flag is unset', () => {
expect(isPerfDiagEnabled({}, false)).toBe(false);
});
});

View File

@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
env: NodeJS.ProcessEnv,
isPackaged: boolean
): boolean {
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
return false;
if (isPackaged) {
return true;
}
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
return false;
}
return true;
return isTruthyFlag(env[PERF_DIAG_ENV]);
}

View File

@@ -1,11 +1,23 @@
import {
app,
BrowserWindow,
ipcMain
ipcMain,
shell
} from 'electron';
import { collectAppMetricsSnapshot } from '../app-metrics';
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
import { getMainWindow } from '../window/create-window';
import { resolveReadablePath } from '../path-jail';
import { sumWorkingSetKb } from './process-metrics.rules';
import { isPerfDiagEnabled } from './diagnostics.flags';
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector';
import {
clearHighMemoryAlert,
readHighMemoryAlert,
writeHighMemoryAlert
} from './high-memory-alert.store';
import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer';
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
let processPollTimer: NodeJS.Timeout | null = null;
let diagnosticsEnabled = false;
let ipcRegistered = false;
let highMemoryAlertTriggeredThisSession = false;
let sessionStartedAt = 0;
export function isPerfDiagActive(): boolean {
return diagnosticsEnabled;
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
return false;
}
});
ipcMain.handle('get-pending-high-memory-alert', async () => {
return readHighMemoryAlert(app.getPath('userData'));
});
ipcMain.handle('acknowledge-high-memory-alert', async () => {
await clearHighMemoryAlert(app.getPath('userData'));
return true;
});
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return {
shown: false,
reason: 'missing-path'
};
}
const scopedPath = await resolveReadablePath(filePath);
if (!scopedPath) {
return {
shown: false,
reason: 'outside-app-data'
};
}
shell.showItemInFolder(scopedPath);
return { shown: true };
});
}
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
});
activeWriter = writer;
highMemoryAlertTriggeredThisSession = false;
sessionStartedAt = Date.now();
registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
const userDataPath = app.getPath('userData');
writer.append({
collectedAt: Date.now(),
source: 'main',
@@ -78,6 +127,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
}
});
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'environment',
payload: {
...collectSessionContext({
sessionStartedAt,
userDataPath
})
}
});
return writer;
}
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
processes: metrics.processes
}
});
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
} catch {
// Collector failures must never affect the app.
}
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
async function maybeTriggerHighMemoryAlert(
writer: PerfDiagWriter,
metrics: AppMetricsSnapshot,
totalWorkingSetKb: number | null
): Promise<void> {
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
return;
}
highMemoryAlertTriggeredThisSession = true;
const detectedAt = Date.now();
const userDataPath = app.getPath('userData');
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
const environment = collectSessionContext({
sessionStartedAt,
userDataPath
});
for (const entry of immediateRendererEntries) {
writer.append(entry);
}
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
});
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: totalWorkingSetKb ?? 0,
metrics,
environment,
mainProcessMemory: process.memoryUsage(),
ringEntries: writer.bufferedEntries,
immediateRendererEntries,
sessionId: writer.sessionId
})
});
await writer.flushSnapshot('high-memory-threshold');
await writeHighMemoryAlert(userDataPath, {
logFilePath: writer.snapshotFilePath,
detectedAt,
peakWorkingSetKb: totalWorkingSetKb ?? 0,
sessionId: writer.sessionId
});
}
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
return {
collectedAt: Number(entry.collectedAt) || Date.now(),

View File

@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'environment'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'high-memory'
| 'crash'
| 'unresponsive';

View File

@@ -7,7 +7,7 @@ import {
resolveDiagnosticsFilePath
} from './diagnostics.rules';
const DEFAULT_RING_CAPACITY = 120;
const DEFAULT_RING_CAPACITY = 300;
const FLUSH_DEBOUNCE_MS = 250;
export interface PerfDiagWriterOptions {
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
export class PerfDiagWriter {
private readonly filePath: string;
private readonly sessionIdValue: string;
private readonly ringCapacity: number;
private readonly pendingLines: string[] = [];
private ring: PerfDiagEntry[] = [];
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
private disabled = false;
constructor(options: PerfDiagWriterOptions) {
this.sessionIdValue = options.sessionId;
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
}
get sessionId(): string {
return this.sessionIdValue;
}
get snapshotFilePath(): string {
return this.filePath;
}

View File

@@ -0,0 +1,27 @@
import {
describe,
expect,
it
} from 'vitest';
import {
exceedsHighMemoryThreshold,
formatWorkingSetGb,
HIGH_MEMORY_THRESHOLD_KB
} from './high-memory-alert.rules';
describe('high-memory-alert.rules', () => {
it('uses a 2 GiB working-set threshold', () => {
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
});
it('detects totals at or above the threshold', () => {
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
});
it('formats working set totals in gigabytes', () => {
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
});
});

View File

@@ -0,0 +1,11 @@
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
return typeof totalWorkingSetKb === 'number'
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
}
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
}

View File

@@ -0,0 +1,64 @@
import * as fsp from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
describe,
expect,
it
} from 'vitest';
import {
clearHighMemoryAlert,
readHighMemoryAlert,
resolveHighMemoryAlertPath,
writeHighMemoryAlert
} from './high-memory-alert.store';
describe('high-memory-alert.store', () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
recursive: true,
force: true
})));
});
it('writes and reads a pending startup alert record', async () => {
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
tempDirs.push(userDataPath);
const record = {
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
detectedAt: 1_700_000_000_000,
peakWorkingSetKb: 2_200_000,
sessionId: 'session-1'
};
await writeHighMemoryAlert(userDataPath, record);
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
);
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
});
it('clears the pending startup alert record', async () => {
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
tempDirs.push(userDataPath);
await writeHighMemoryAlert(userDataPath, {
logFilePath: '/tmp/perf.jsonl',
detectedAt: Date.now(),
peakWorkingSetKb: 2_100_000,
sessionId: 'session-2'
});
await clearHighMemoryAlert(userDataPath);
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface HighMemoryAlertRecord {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}
export function resolveHighMemoryAlertPath(userDataPath: string): string {
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
}
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
try {
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
if (
typeof parsed.logFilePath !== 'string'
|| !parsed.logFilePath.trim()
|| typeof parsed.detectedAt !== 'number'
|| typeof parsed.peakWorkingSetKb !== 'number'
|| typeof parsed.sessionId !== 'string'
) {
return null;
}
return {
logFilePath: parsed.logFilePath,
detectedAt: parsed.detectedAt,
peakWorkingSetKb: parsed.peakWorkingSetKb,
sessionId: parsed.sessionId
};
} catch {
return null;
}
}
export async function writeHighMemoryAlert(
userDataPath: string,
record: HighMemoryAlertRecord
): Promise<void> {
const filePath = resolveHighMemoryAlertPath(userDataPath);
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
}
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
try {
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
} catch {
// Missing pending alert is fine.
}
}

View File

@@ -0,0 +1,201 @@
import {
describe,
expect,
it
} from 'vitest';
import type { PerfDiagEntry } from './diagnostics.models';
import {
buildHighMemoryDiagnosticPayload,
buildHighMemorySummary,
extractLatestRendererSamples,
extractProcessHistory,
formatMemoryUsageMb,
rankProcessesByWorkingSet,
summarizeRingBuffer
} from './high-memory-snapshot.rules';
function createProcess(overrides: Partial<{
pid: number;
type: string;
workingSetKb: number | null;
peakWorkingSetKb: number | null;
privateBytesKb: number | null;
creationTime: number | null;
cpuPercent: number | null;
}> = {}) {
return {
pid: 1,
type: 'Tab',
workingSetKb: 1024,
peakWorkingSetKb: null,
privateBytesKb: null,
creationTime: null,
cpuPercent: null,
...overrides
};
}
describe('high-memory-snapshot.rules', () => {
it('ranks processes by working set and computes share percentages', () => {
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
expect(ranked[0]?.type).toBe('GPU');
expect(ranked[0]?.sharePercent).toBe(75);
expect(ranked[1]?.sharePercent).toBe(25);
});
it('extracts the latest renderer store, heap, and component samples', () => {
const entries: PerfDiagEntry[] = [
{
collectedAt: 1,
source: 'renderer',
type: 'store',
payload: { domains: { chat: 100 } }
},
{
collectedAt: 2,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 120 }
},
{
collectedAt: 3,
source: 'renderer',
type: 'components',
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
},
{
collectedAt: 4,
source: 'renderer',
type: 'store',
payload: { domains: { chat: 500 } }
}
];
expect(extractLatestRendererSamples(entries)).toEqual({
store: { domains: { chat: 500 } },
heap: { usedJsHeapMb: 120 },
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
});
});
it('extracts recent process history from the ring buffer', () => {
const entries: PerfDiagEntry[] = [
{
collectedAt: 1,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 1000 }
},
{
collectedAt: 2,
source: 'main',
type: 'session',
payload: { event: 'noop' }
},
{
collectedAt: 3,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 2000 }
}
];
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
});
it('summarizes ring buffer entry counts', () => {
expect(summarizeRingBuffer([
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
])).toEqual({
'main:process': 2,
'renderer:heap': 1
});
});
it('builds a high-memory summary with threshold context', () => {
const summary = buildHighMemorySummary(
2_200_000,
[createProcess({ workingSetKb: 2_200_000 })],
1_700_000_000_000
);
expect(summary.totalWorkingSetGb).toBe('2.10');
expect(summary.thresholdGb).toBe('2.00');
expect(summary.topProcesses).toHaveLength(1);
});
it('builds a comprehensive high-memory diagnostic payload', () => {
const payload = buildHighMemoryDiagnosticPayload({
detectedAt: 1_700_000_000_000,
totalWorkingSetKb: 2_200_000,
metrics: {
collectedAt: 1_700_000_000_000,
processes: [
createProcess({
workingSetKb: 2_200_000,
peakWorkingSetKb: 2_300_000,
privateBytesKb: 1_800_000,
creationTime: 1,
cpuPercent: 12
})
]
},
environment: { appVersion: '1.0.0' },
mainProcessMemory: {
rss: 64 * 1024 * 1024,
heapTotal: 32 * 1024 * 1024,
heapUsed: 16 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 1024
},
ringEntries: [
{
collectedAt: 1,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 2_000_000 }
}
],
immediateRendererEntries: [
{
collectedAt: 2,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 300, route: '/room/abc' }
}
],
sessionId: 'session-1'
});
expect(payload.event).toBe('high-memory-threshold');
expect(payload.summary).toMatchObject({
totalWorkingSetKb: 2_200_000
});
expect(payload.processHistory).toHaveLength(1);
expect(payload.recentRendererSamples).toEqual({
store: null,
heap: { usedJsHeapMb: 300, route: '/room/abc' },
components: null
});
expect(formatMemoryUsageMb({
rss: 64 * 1024 * 1024,
heapTotal: 32 * 1024 * 1024,
heapUsed: 16 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 1024
})).toEqual({
rssMb: 64,
heapTotalMb: 32,
heapUsedMb: 16,
externalMb: 8,
arrayBuffersMb: 0
});
});
});

View File

@@ -0,0 +1,179 @@
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
import type { PerfDiagEntry } from './diagnostics.models';
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
import type { SessionContextSnapshot } from './session-context.collector';
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
sharePercent: number;
}
export interface HighMemorySummary {
detectedAt: number;
thresholdKb: number;
thresholdGb: string;
totalWorkingSetKb: number;
totalWorkingSetGb: string;
topProcesses: RankedProcessSnapshot[];
}
export interface LatestRendererSamples {
store: Record<string, unknown> | null;
heap: Record<string, unknown> | null;
components: Record<string, unknown> | null;
}
export function rankProcessesByWorkingSet(
processes: readonly AppMetricsProcessSnapshot[],
totalWorkingSetKb: number | null
): RankedProcessSnapshot[] {
const total = totalWorkingSetKb ?? 0;
return [...processes]
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
.map((process) => ({
...process,
sharePercent: total > 0
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
: 0
}));
}
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
let store: Record<string, unknown> | null = null;
let heap: Record<string, unknown> | null = null;
let components: Record<string, unknown> | null = null;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.source !== 'renderer') {
continue;
}
if (!store && entry.type === 'store') {
store = entry.payload;
}
if (!heap && entry.type === 'heap') {
heap = entry.payload;
}
if (!components && entry.type === 'components') {
components = entry.payload;
}
if (store && heap && components) {
break;
}
}
return {
store,
heap,
components
};
}
export function extractProcessHistory(
entries: readonly PerfDiagEntry[],
limit = 24
): Record<string, unknown>[] {
const history: Record<string, unknown>[] = [];
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.type !== 'process') {
continue;
}
history.unshift({
collectedAt: entry.collectedAt,
...entry.payload
});
if (history.length >= limit) {
break;
}
}
return history;
}
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
const key = `${entry.source}:${entry.type}`;
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
export function buildHighMemorySummary(
totalWorkingSetKb: number,
processes: readonly AppMetricsProcessSnapshot[],
detectedAt: number
): HighMemorySummary {
return {
detectedAt,
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
totalWorkingSetKb,
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
};
}
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
return {
rssMb: roundMb(memoryUsage.rss),
heapTotalMb: roundMb(memoryUsage.heapTotal),
heapUsedMb: roundMb(memoryUsage.heapUsed),
externalMb: roundMb(memoryUsage.external),
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
};
}
export function buildHighMemoryDiagnosticPayload(input: {
detectedAt: number;
totalWorkingSetKb: number;
metrics: AppMetricsSnapshot;
environment: SessionContextSnapshot;
mainProcessMemory: NodeJS.MemoryUsage;
ringEntries: readonly PerfDiagEntry[];
immediateRendererEntries: readonly PerfDiagEntry[];
sessionId: string;
}): Record<string, unknown> {
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
return {
event: 'high-memory-threshold',
sessionId: input.sessionId,
summary: buildHighMemorySummary(
input.totalWorkingSetKb,
input.metrics.processes,
input.detectedAt
),
environment: input.environment,
metrics: input.metrics,
mainProcessMemory: input.mainProcessMemory,
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
processHistory: extractProcessHistory(mergedRingEntries),
ringSummary: summarizeRingBuffer(mergedRingEntries),
recentRendererSamples,
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
collectedAt: entry.collectedAt,
type: entry.type,
payload: entry.payload
}))
};
}
function roundMb(bytes: number): number {
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
}

View File

@@ -0,0 +1,39 @@
import type { BrowserWindow } from 'electron';
import type { PerfDiagEntry } from './diagnostics.models';
export async function collectImmediateRendererSamples(
window: BrowserWindow | null | undefined
): Promise<PerfDiagEntry[]> {
if (!window || window.isDestroyed()) {
return [];
}
try {
const result = await window.webContents.executeJavaScript(`
(function () {
const collect = globalThis.__collectPerfDiagSample;
return typeof collect === 'function' ? collect() : [];
})()
`, true);
if (!Array.isArray(result)) {
return [];
}
return result
.filter((entry) => entry && typeof entry === 'object')
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
} catch {
return [];
}
}
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
return {
collectedAt: Number(entry.collectedAt) || Date.now(),
source: 'renderer',
type: entry.type ?? 'session',
payload: entry.payload ?? {}
};
}

View File

@@ -1,4 +1,16 @@
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
export {
clearHighMemoryAlert,
readHighMemoryAlert,
resolveHighMemoryAlertPath,
writeHighMemoryAlert
} from './high-memory-alert.store';
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
export {
exceedsHighMemoryThreshold,
formatWorkingSetGb,
HIGH_MEMORY_THRESHOLD_KB
} from './high-memory-alert.rules';
export {
attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered,

View File

@@ -0,0 +1,91 @@
import { app, BrowserWindow } from 'electron';
import * as os from 'os';
export interface SessionWindowSnapshot {
id: number;
title: string;
url: string | null;
focused: boolean;
visible: boolean;
destroyed: boolean;
}
export interface SessionContextSnapshot {
collectedAt: number;
sessionStartedAt: number;
uptimeMs: number;
appVersion: string;
electronVersion: string;
chromeVersion: string;
nodeVersion: string;
platform: NodeJS.Platform;
arch: string;
osType: string;
osRelease: string;
osVersion: string | null;
totalMemKb: number;
freeMemKb: number;
userDataPath: string;
appPath: string;
isPackaged: boolean;
locale: string;
windowCount: number;
windows: SessionWindowSnapshot[];
}
export function collectSessionContext(input: {
sessionStartedAt: number;
userDataPath: string;
}): SessionContextSnapshot {
const collectedAt = Date.now();
return {
collectedAt,
sessionStartedAt: input.sessionStartedAt,
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
appVersion: app.getVersion(),
electronVersion: process.versions.electron ?? 'unknown',
chromeVersion: process.versions.chrome ?? 'unknown',
nodeVersion: process.versions.node ?? 'unknown',
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: readOsVersion(),
totalMemKb: Math.round(os.totalmem() / 1024),
freeMemKb: Math.round(os.freemem() / 1024),
userDataPath: input.userDataPath,
appPath: app.getAppPath(),
isPackaged: app.isPackaged,
locale: app.getLocale(),
windowCount: BrowserWindow.getAllWindows().length,
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
};
}
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
let url: string | null = null;
try {
url = window.webContents.getURL() || null;
} catch {
url = null;
}
return {
id: window.id,
title: window.getTitle(),
url,
focused: window.isFocused(),
visible: window.isVisible(),
destroyed: window.isDestroyed()
};
}
function readOsVersion(): string | null {
try {
return os.version?.() ?? null;
} catch {
return null;
}
}

View File

@@ -259,6 +259,14 @@ export interface ElectronAPI {
type: string;
payload: Record<string, unknown>;
}) => Promise<boolean>;
getPendingHighMemoryAlert: () => Promise<{
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
} | null>;
acknowledgeHighMemoryAlert: () => Promise<boolean>;
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'),