diff --git a/packages/core/src/telemetry/heap-snapshot.test.ts b/packages/core/src/telemetry/heap-snapshot.test.ts new file mode 100644 index 0000000000..9180a5338d --- /dev/null +++ b/packages/core/src/telemetry/heap-snapshot.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import v8 from 'node:v8'; +import fs from 'node:fs'; +import { captureHeapSnapshot } from './heap-snapshot.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +vi.mock('node:v8'); +vi.mock('node:fs'); +vi.mock('../utils/debugLogger.js', () => ({ + debugLogger: { + error: vi.fn(), + }, +})); + +describe('heap-snapshot', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should capture a heap snapshot to a secure directory', () => { + vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-heap-abc123'); + + const filePath = captureHeapSnapshot(); + + expect(filePath).toContain('gemini-heap-abc123'); + expect(filePath).toContain('.heapsnapshot'); + expect(v8.writeHeapSnapshot).toHaveBeenCalledWith(filePath); + }); + + it('should return null and log an error if capture fails', () => { + vi.mocked(fs.mkdtempSync).mockImplementation(() => { + throw new Error('Disk full'); + }); + + const result = captureHeapSnapshot(); + + expect(result).toBeNull(); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to capture heap snapshot'), + expect.any(Error), + ); + }); +}); diff --git a/packages/core/src/telemetry/heap-snapshot.ts b/packages/core/src/telemetry/heap-snapshot.ts new file mode 100644 index 0000000000..5ad2155164 --- /dev/null +++ b/packages/core/src/telemetry/heap-snapshot.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import v8 from 'node:v8'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Utility to capture a V8 heap snapshot. + * Snapshots are saved to a secure, uniquely named temporary directory. + * + * @returns The absolute path to the generated .heapsnapshot file, or null if it failed. + */ +export function captureHeapSnapshot(): string | null { + try { + const timestamp = Date.now(); + const filename = `gemini-heap-${timestamp}.heapsnapshot`; + + // Use mkdtempSync for a secure, uniquely named directory (mitigates symlink attacks) + const snapshotsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-heap-')); + const filePath = path.join(snapshotsDir, filename); + + // Note: v8.writeHeapSnapshot is a synchronous, blocking operation. + // This is intentional during diagnostics to capture a consistent heap state. + v8.writeHeapSnapshot(filePath); + + return filePath; + } catch (error) { + // Telemetry/diagnostic failures should not crash the application + debugLogger.error('Failed to capture heap snapshot:', error); + return null; + } +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index d3cc033341..83e5517882 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -92,6 +92,7 @@ export { startGlobalMemoryMonitoring, stopGlobalMemoryMonitoring, } from './memory-monitor.js'; +export { captureHeapSnapshot } from './heap-snapshot.js'; export type { MemorySnapshot, ProcessMetrics } from './memory-monitor.js'; export { EventLoopMonitor, diff --git a/packages/core/src/telemetry/memory-monitor.ts b/packages/core/src/telemetry/memory-monitor.ts index aeaecc6ca0..7214203099 100644 --- a/packages/core/src/telemetry/memory-monitor.ts +++ b/packages/core/src/telemetry/memory-monitor.ts @@ -17,6 +17,7 @@ import { isPerformanceMonitoringActive, } from './metrics.js'; import { RateLimiter } from './rate-limiter.js'; +import { captureHeapSnapshot } from './heap-snapshot.js'; export interface MemorySnapshot { timestamp: number; @@ -386,6 +387,14 @@ export class MemoryMonitor { this.highWaterMarkTracker.resetAllHighWaterMarks(); } + /** + * Capture a V8 heap snapshot for memory diagnostics. + * @returns The absolute path to the generated .heapsnapshot file, or null if it failed. + */ + captureHeapSnapshot(): string | null { + return captureHeapSnapshot(); + } + /** * Cleanup resources */