feat(bf-5r8a): add memoryProfiler module for real-time memory profiling
Implements the MemoryProfiler class with: - Real-time memory usage tracking and trend analysis - Baseline/diff functionality for leak detection - V8 heap snapshot capture to disk - In-memory snapshot ring buffer (max 100) - Periodic capture support with configurable intervals Exports getMemoryProfiler() singleton used by server.ts endpoints: - GET /api/memory/stats - POST /api/memory/capture - GET /api/memory/diff - POST /api/memory/baseline - POST /api/memory/heap-snapshot - GET /api/memory/snapshots Resolves bd-ch6.7 memory profiling plan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
22178ca862
commit
f824c2e9f7
1 changed files with 254 additions and 0 deletions
254
src/memoryProfiler.ts
Normal file
254
src/memoryProfiler.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
/**
|
||||||
|
* FABRIC Memory Profiler
|
||||||
|
*
|
||||||
|
* Real-time memory profiling and leak detection utilities.
|
||||||
|
* Tracks memory usage over time, captures snapshots, and provides diff analysis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
/** Snapshot directory for heap snapshots */
|
||||||
|
const SNAPSHOT_DIR = join(homedir(), '.needle', 'snapshots');
|
||||||
|
|
||||||
|
/** Maximum number of in-memory snapshots to keep */
|
||||||
|
const MAX_IN_MEMORY_SNAPSHOTS = 100;
|
||||||
|
|
||||||
|
/** Snapshot interval in milliseconds */
|
||||||
|
const SNAPSHOT_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
|
export interface MemorySnapshot {
|
||||||
|
timestamp: number;
|
||||||
|
rss: number;
|
||||||
|
heapUsed: number;
|
||||||
|
heapTotal: number;
|
||||||
|
external: number;
|
||||||
|
arrayBuffers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryStats {
|
||||||
|
current: MemorySnapshot;
|
||||||
|
trend: 'stable' | 'rising' | 'falling' | 'unknown';
|
||||||
|
avgRss: number;
|
||||||
|
maxRss: number;
|
||||||
|
minRss: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryDiff {
|
||||||
|
baseline: MemorySnapshot;
|
||||||
|
current: MemorySnapshot;
|
||||||
|
durationMs: number;
|
||||||
|
rssDelta: number;
|
||||||
|
heapUsedDelta: number;
|
||||||
|
heapTotalDelta: number;
|
||||||
|
externalDelta: number;
|
||||||
|
arrayBuffersDelta: number;
|
||||||
|
percentChange: {
|
||||||
|
rss: number;
|
||||||
|
heapUsed: number;
|
||||||
|
heapTotal: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryProfiler {
|
||||||
|
private snapshots: MemorySnapshot[] = [];
|
||||||
|
private baseline: MemorySnapshot | null = null;
|
||||||
|
private lastCapture: number = 0;
|
||||||
|
private periodicInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/** CLI properties for heap snapshot configuration */
|
||||||
|
writeSnapshots: boolean = false;
|
||||||
|
autoSnapshot: boolean = false;
|
||||||
|
snapshotIntervalMs: number = 30 * 60 * 1000; // Default 30 minutes
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Ensure snapshot directory exists
|
||||||
|
if (!existsSync(SNAPSHOT_DIR)) {
|
||||||
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture current memory usage */
|
||||||
|
capture(): MemorySnapshot {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
const snapshot: MemorySnapshot = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rss: usage.rss,
|
||||||
|
heapUsed: usage.heapUsed,
|
||||||
|
heapTotal: usage.heapTotal,
|
||||||
|
external: usage.external,
|
||||||
|
arrayBuffers: usage.arrayBuffers,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.snapshots.push(snapshot);
|
||||||
|
this.lastCapture = snapshot.timestamp;
|
||||||
|
|
||||||
|
// Prune old snapshots if we exceed the limit
|
||||||
|
if (this.snapshots.length > MAX_IN_MEMORY_SNAPSHOTS) {
|
||||||
|
this.snapshots = this.snapshots.slice(-MAX_IN_MEMORY_SNAPSHOTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current memory statistics with trend analysis */
|
||||||
|
getStats(): MemoryStats {
|
||||||
|
// Ensure we have at least one snapshot
|
||||||
|
if (this.snapshots.length === 0) {
|
||||||
|
this.capture();
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.snapshots[this.snapshots.length - 1];
|
||||||
|
const rssValues = this.snapshots.map(s => s.rss);
|
||||||
|
|
||||||
|
const avgRss = rssValues.reduce((a, b) => a + b, 0) / rssValues.length;
|
||||||
|
const maxRss = Math.max(...rssValues);
|
||||||
|
const minRss = Math.min(...rssValues);
|
||||||
|
|
||||||
|
// Determine trend based on recent samples
|
||||||
|
let trend: 'stable' | 'rising' | 'falling' | 'unknown' = 'unknown';
|
||||||
|
if (this.snapshots.length >= 3) {
|
||||||
|
const recent = this.snapshots.slice(-10);
|
||||||
|
const firstHalf = recent.slice(0, Math.floor(recent.length / 2));
|
||||||
|
const secondHalf = recent.slice(Math.floor(recent.length / 2));
|
||||||
|
|
||||||
|
const firstAvg = firstHalf.reduce((sum, s) => sum + s.heapUsed, 0) / firstHalf.length;
|
||||||
|
const secondAvg = secondHalf.reduce((sum, s) => sum + s.heapUsed, 0) / secondHalf.length;
|
||||||
|
|
||||||
|
const changePercent = ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||||
|
|
||||||
|
if (changePercent > 5) {
|
||||||
|
trend = 'rising';
|
||||||
|
} else if (changePercent < -5) {
|
||||||
|
trend = 'falling';
|
||||||
|
} else {
|
||||||
|
trend = 'stable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
trend,
|
||||||
|
avgRss,
|
||||||
|
maxRss,
|
||||||
|
minRss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set current memory state as baseline for future comparisons */
|
||||||
|
setBaseline(): MemorySnapshot {
|
||||||
|
this.baseline = this.capture();
|
||||||
|
return this.baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get diff from baseline, or null if no baseline set */
|
||||||
|
diffFromBaseline(): MemoryDiff | null {
|
||||||
|
if (!this.baseline || this.snapshots.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.snapshots[this.snapshots.length - 1];
|
||||||
|
const durationMs = current.timestamp - this.baseline.timestamp;
|
||||||
|
|
||||||
|
const rssDelta = current.rss - this.baseline.rss;
|
||||||
|
const heapUsedDelta = current.heapUsed - this.baseline.heapUsed;
|
||||||
|
const heapTotalDelta = current.heapTotal - this.baseline.heapTotal;
|
||||||
|
const externalDelta = current.external - this.baseline.external;
|
||||||
|
const arrayBuffersDelta = current.arrayBuffers - this.baseline.arrayBuffers;
|
||||||
|
|
||||||
|
const percentChange = {
|
||||||
|
rss: this.baseline.rss > 0 ? (rssDelta / this.baseline.rss) * 100 : 0,
|
||||||
|
heapUsed: this.baseline.heapUsed > 0 ? (heapUsedDelta / this.baseline.heapUsed) * 100 : 0,
|
||||||
|
heapTotal: this.baseline.heapTotal > 0 ? (heapTotalDelta / this.baseline.heapTotal) * 100 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseline: this.baseline,
|
||||||
|
current,
|
||||||
|
durationMs,
|
||||||
|
rssDelta,
|
||||||
|
heapUsedDelta,
|
||||||
|
heapTotalDelta,
|
||||||
|
externalDelta,
|
||||||
|
arrayBuffersDelta,
|
||||||
|
percentChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get recent snapshots */
|
||||||
|
getRecent(count: number): MemorySnapshot[] {
|
||||||
|
return this.snapshots.slice(-count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format memory values as human-readable string */
|
||||||
|
formatMemory(snapshot: MemorySnapshot): string {
|
||||||
|
const format = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)}KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return `RSS=${format(snapshot.rss)}, Heap=${format(snapshot.heapUsed)}/${format(snapshot.heapTotal)}, External=${format(snapshot.external)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a V8 heap snapshot to disk */
|
||||||
|
writeHeapSnapshot(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - v8 module exists in Node.js but not in TypeScript types
|
||||||
|
const v8 = require('v8');
|
||||||
|
const filename = `heap-${Date.now()}.heapsnapshot`;
|
||||||
|
const filepath = join(SNAPSHOT_DIR, filename);
|
||||||
|
|
||||||
|
v8.writeHeapSnapshot(filepath);
|
||||||
|
|
||||||
|
resolve(filepath);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start periodic memory capture and snapshot writing */
|
||||||
|
startPeriodicCapture(): void {
|
||||||
|
if (this.periodicInterval) {
|
||||||
|
return; // Already running
|
||||||
|
}
|
||||||
|
|
||||||
|
this.periodicInterval = setInterval(() => {
|
||||||
|
this.capture();
|
||||||
|
|
||||||
|
if (this.writeSnapshots && this.autoSnapshot) {
|
||||||
|
this.writeHeapSnapshot()
|
||||||
|
.then(filepath => console.error(`Heap snapshot written: ${filepath}`))
|
||||||
|
.catch(err => console.error(`Failed to write heap snapshot: ${err}`));
|
||||||
|
}
|
||||||
|
}, this.snapshotIntervalMs);
|
||||||
|
|
||||||
|
// Unref the interval so it doesn't keep the process alive
|
||||||
|
if (this.periodicInterval.unref) {
|
||||||
|
this.periodicInterval.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop periodic memory capture */
|
||||||
|
stopPeriodicCapture(): void {
|
||||||
|
if (this.periodicInterval) {
|
||||||
|
clearInterval(this.periodicInterval);
|
||||||
|
this.periodicInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton instance */
|
||||||
|
let profilerInstance: MemoryProfiler | null = null;
|
||||||
|
|
||||||
|
/** Get or create the memory profiler singleton */
|
||||||
|
export function getMemoryProfiler(): MemoryProfiler {
|
||||||
|
if (!profilerInstance) {
|
||||||
|
profilerInstance = new MemoryProfiler();
|
||||||
|
}
|
||||||
|
return profilerInstance;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue