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:
jedarden 2026-05-02 14:29:47 -04:00
parent 22178ca862
commit f824c2e9f7

254
src/memoryProfiler.ts Normal file
View 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;
}