Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions e2e-tests/performance_monitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ testWithConfig({})(
expect(
settings.lastKnownPerformance.systemCpuPercent,
).toBeGreaterThanOrEqual(0);
// statfs works on every platform we ship, so a real capture always has
// disk figures. Used never exceeds total; available can trail both.
expect(settings.lastKnownPerformance.diskTotalMB).toBeGreaterThan(0);
Comment thread
RyanGroch marked this conversation as resolved.
expect(settings.lastKnownPerformance.diskUsedMB).toBeGreaterThan(0);
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.
expect(settings.lastKnownPerformance.diskUsedMB).toBeLessThanOrEqual(
settings.lastKnownPerformance.diskTotalMB,
);
expect(settings.lastKnownPerformance.diskAvailableMB).toBeLessThanOrEqual(
settings.lastKnownPerformance.diskTotalMB,
);

// Verify the timestamp is recent (within the last minute)
const now = Date.now();
Expand Down
6 changes: 6 additions & 0 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ export const LastKnownPerformanceSchema = z.object({
systemMemoryUsageMB: z.number().optional(),
systemMemoryTotalMB: z.number().optional(),
systemCpuPercent: z.number().optional(),
// Capacity of the volume holding the user data directory. diskUsedMB counts
// every allocated block; diskAvailableMB excludes space the platform holds
// back (root reserve, quota), so the two need not sum to diskTotalMB.
diskTotalMB: z.number().optional(),
diskUsedMB: z.number().optional(),
diskAvailableMB: z.number().optional(),
// Main process V8 heap, from v8.getHeapStatistics().
heapUsedMB: z.number().optional(),
heapLimitMB: z.number().optional(),
Expand Down
9 changes: 9 additions & 0 deletions src/utils/crash_telemetry_fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ describe("crashPerformanceEventFields", () => {
memoryUsageMB: 400,
heapUsedMB: 512,
heapLimitMB: 4144,
diskTotalMB: 476000,
diskUsedMB: 401000,
diskAvailableMB: 51000,
processWorkingSetsMB: { browser: 400, tab: 900, zygote: 30, unknown: 20 },
activity: {
activeStreams: 1,
Expand Down Expand Up @@ -45,6 +48,11 @@ describe("crashPerformanceEventFields", () => {
expect(fields.peak_active_streams).toBe(2);
expect(fields.peak_ts_utility_process).toBeNull();
expect(fields.peak_heap_used_mb).toBe(1024);
expect(fields.last_known_disk_total_mb).toBe(476000);
expect(fields.last_known_disk_used_mb).toBe(401000);
// Reported separately from used because space the platform withholds
// sits between them: used + available is short of total.
expect(fields.last_known_disk_available_mb).toBe(51000);

// No object-valued properties: PostHog cannot filter nested JSON.
for (const value of Object.values(fields)) {
Expand All @@ -60,6 +68,7 @@ describe("crashPerformanceEventFields", () => {

expect(fields.last_known_memory_mb).toBe(400);
expect(fields.last_known_working_set_browser_mb).toBeUndefined();
expect(fields.last_known_disk_total_mb).toBeUndefined();
expect(fields.last_known_active_streams).toBeUndefined();
expect(fields.peak_active_streams).toBeUndefined();
});
Expand Down
3 changes: 3 additions & 0 deletions src/utils/crash_telemetry_fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export function crashPerformanceEventFields(
last_known_system_memory_mb: perf.systemMemoryUsageMB,
last_known_system_memory_total_mb: perf.systemMemoryTotalMB,
last_known_system_cpu_pct: perf.systemCpuPercent,
last_known_disk_total_mb: perf.diskTotalMB,
last_known_disk_used_mb: perf.diskUsedMB,
last_known_disk_available_mb: perf.diskAvailableMB,
last_known_snapshot_timestamp: perf.timestamp,
time_since_last_heartbeat_ms: Date.now() - perf.timestamp,
last_known_heap_used_mb: perf.heapUsedMB,
Expand Down
63 changes: 63 additions & 0 deletions src/utils/disk_usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import fs from "node:fs";
import { getDiskUsageMB } from "@/utils/disk_usage";

vi.mock("node:fs", () => ({
default: { statfsSync: vi.fn() },
}));

const statfsSync = vi.mocked(fs.statfsSync);

// 4KiB blocks: 262144 total = 1024MB, 65536 free = 256MB, 32768 available
// to non-root = 128MB. The gap between free and available is the reserve.
function statfsResult(overrides: Partial<fs.StatsFs> = {}): fs.StatsFs {
return {
type: 61267,
bsize: 4096,
blocks: 262144,
bfree: 65536,
bavail: 32768,
files: 0,
ffree: 0,
...overrides,
} as fs.StatsFs;
}

describe("getDiskUsageMB", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("converts blocks to MB and reports used and available separately", () => {
statfsSync.mockReturnValue(statfsResult());

expect(getDiskUsageMB("/some/path")).toEqual({
totalMB: 1024,
// Every allocated block, including the root reserve.
usedMB: 768,
// Excludes the reserve, so used + available is short of total.
availableMB: 128,
});
expect(statfsSync).toHaveBeenCalledExactlyOnceWith("/some/path");
});

it("scales with the filesystem's block size", () => {
statfsSync.mockReturnValue(
statfsResult({ bsize: 1024, blocks: 2048, bfree: 1024, bavail: 1024 }),
);

expect(getDiskUsageMB("/some/path")).toEqual({
totalMB: 2,
usedMB: 1,
availableMB: 1,
});
});

it("returns null when the path cannot be read", () => {
statfsSync.mockImplementation(() => {
throw new Error("ENOENT");
});

expect(getDiskUsageMB("/missing")).toBeNull();
});
});
31 changes: 31 additions & 0 deletions src/utils/disk_usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from "node:fs";

const BYTES_PER_MB = 1024 * 1024;

export interface DiskUsageMB {
totalMB: number;
usedMB: number;
availableMB: number;
}

/**
* Capacity of the filesystem holding `targetPath`, from one statfs syscall.
* usedMB counts every allocated block, while availableMB is what this user
* can actually write and is lower wherever the platform holds space back —
* a root reserve, a per-user quota. Returns null when the path is unreadable
* so callers can omit the fields rather than report a zero.
*/
export function getDiskUsageMB(targetPath: string): DiskUsageMB | null {
try {
const stats = fs.statfsSync(targetPath);
const toMB = (blocks: number) =>
Math.round((blocks * stats.bsize) / BYTES_PER_MB);
return {
totalMB: toMB(stats.blocks),
Comment thread
RyanGroch marked this conversation as resolved.
usedMB: toMB(stats.blocks - stats.bfree),
availableMB: toMB(stats.bavail),
Comment thread
RyanGroch marked this conversation as resolved.
};
} catch {
return null;
Comment thread
RyanGroch marked this conversation as resolved.
}
}
10 changes: 10 additions & 0 deletions src/utils/performance_monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import { getActiveStreamCount } from "../ipc/handlers/chat_stream_handlers";
import { runningApps } from "../ipc/utils/process_manager";
import { typescriptUtilityProcessScheduler } from "../ipc/processors/typescript_utility_process_scheduler";
import { getUserDataPath } from "../paths/paths";
import { getDiskUsageMB } from "./disk_usage";

const logger = log.scope("performance-monitor");

Expand Down Expand Up @@ -231,6 +233,9 @@ function capturePerformanceMetrics() {
: 0;
const kernelPeakRssMB = getKernelPeakRssMB();
const activity = snapshotActivity();
// The user data volume, not the apps volume: writeSettings below already
// blocks on it every tick, so this adds no new main-thread exposure.
const diskUsage = getDiskUsageMB(getUserDataPath());
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.

logger.debug(
`Performance: Memory=${memoryUsageMB}MB, Heap=${heapUsedMB}/${heapLimitMB}MB, All Processes=${allProcessesMemoryMB ?? "?"}MB, CPU=${cpuUsagePercent}%, System Memory=${systemMemory.usedMemoryMB}/${systemMemory.totalMemoryMB}MB (${systemMemory.usagePercent}%), System CPU=${systemCpuPercent}%`,
Expand Down Expand Up @@ -271,6 +276,11 @@ function capturePerformanceMetrics() {
systemMemoryUsageMB: systemMemory.usedMemoryMB,
systemMemoryTotalMB: systemMemory.totalMemoryMB,
systemCpuPercent,
...(diskUsage && {
diskTotalMB: diskUsage.totalMB,
diskUsedMB: diskUsage.usedMB,
diskAvailableMB: diskUsage.availableMB,
Comment thread
RyanGroch marked this conversation as resolved.
}),
heapUsedMB,
heapLimitMB,
...(processWorkingSetsMB && { processWorkingSetsMB }),
Expand Down
Loading