diff --git a/packages/utils/docs/profiler.md b/packages/utils/docs/profiler.md new file mode 100644 index 000000000..982f29739 --- /dev/null +++ b/packages/utils/docs/profiler.md @@ -0,0 +1,20 @@ +# Profile + +The `Profiler` class provides a clean, type-safe API for performance monitoring that integrates seamlessly with Chrome DevTools. It supports both synchronous and asynchronous operations with smart defaults for custom track visualization, enabling developers to track performance bottlenecks and optimize application speed. + +## Features + +- **Type-Safe API**: Fully typed UserTiming API for [Chrome DevTools Extensibility API](https://developer.chrome.com/docs/devtools/performance/extension) +- **Measure API**: Easy-to-use methods for measuring synchronous and asynchronous code execution times. +- **Custom Track Configuration**: Fully typed reusable configurations for custom track visualization. +- **Process buffered entries**: Captures and processes buffered profiling entries. +- **3rd Party Profiling**: Automatically processes third-party performance entries. +- **Clean measure names**: Automatically adds prefixes to measure names, as well as start/end postfix to marks, for better organization. + +## NodeJS Features + +- **Crash-save Write Ahead Log**: Ensures profiling data is saved even if the application crashes. +- **Recoverable Profiles**: Ability to resume profiling sessions after interruptions or crash. +- **Automatic Trace Generation**: Generates trace files compatible with Chrome DevTools for in-depth performance analysis. +- **Multiprocess Support**: Designed to handle profiling over sharded WAL. +- **Controllable over env vars**: Easily enable or disable profiling through environment variables. diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts index 7f3d0850a..5e072da0e 100644 --- a/packages/utils/src/lib/exit-process.int.test.ts +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -1,8 +1,8 @@ import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; +import { SIGNAL_EXIT_CODES, subscribeProcessExit } from './exit-process.js'; -describe('installExitHandlers', () => { +describe('subscribeProcessExit', () => { const onError = vi.fn(); const onExit = vi.fn(); const processOnSpy = vi.spyOn(process, 'on'); @@ -26,7 +26,7 @@ describe('installExitHandlers', () => { }); it('should install event listeners for all expected events', () => { - expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onError, onExit })).not.toThrow(); expect(processOnSpy).toHaveBeenCalledWith( 'uncaughtException', @@ -43,7 +43,7 @@ describe('installExitHandlers', () => { }); it('should call onError with error and kind for uncaughtException', () => { - expect(() => installExitHandlers({ onError })).not.toThrow(); + expect(() => subscribeProcessExit({ onError })).not.toThrow(); const testError = new Error('Test uncaught exception'); @@ -55,7 +55,7 @@ describe('installExitHandlers', () => { }); it('should call onError with reason and kind for unhandledRejection', () => { - expect(() => installExitHandlers({ onError })).not.toThrow(); + expect(() => subscribeProcessExit({ onError })).not.toThrow(); const testReason = 'Test unhandled rejection'; @@ -67,7 +67,7 @@ describe('installExitHandlers', () => { }); it('should call onExit and exit with code 0 for SIGINT', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('SIGINT'); @@ -80,7 +80,7 @@ describe('installExitHandlers', () => { }); it('should call onExit and exit with code 0 for SIGTERM', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('SIGTERM'); @@ -93,7 +93,7 @@ describe('installExitHandlers', () => { }); it('should call onExit and exit with code 0 for SIGQUIT', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('SIGQUIT'); @@ -106,7 +106,7 @@ describe('installExitHandlers', () => { }); it('should call onExit for successful process termination with exit code 0', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('exit', 0); @@ -117,7 +117,7 @@ describe('installExitHandlers', () => { }); it('should call onExit for failed process termination with exit code 1', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('exit', 1); diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts index 62cee4977..ca0bcf3cf 100644 --- a/packages/utils/src/lib/exit-process.ts +++ b/packages/utils/src/lib/exit-process.ts @@ -38,7 +38,19 @@ export type ExitHandlerOptions = { fatalExitCode?: number; }; -export function installExitHandlers(options: ExitHandlerOptions = {}): void { +/** + * + * @param options - Options for the exit handler + * @param options.onExit - Callback to be called when the process exits + * @param options.onError - Callback to be called when an error occurs + * @param options.exitOnFatal - Whether to exit the process on fatal errors + * @param options.exitOnSignal - Whether to exit the process on signals + * @param options.fatalExitCode - The exit code to use for fatal errors + * @returns A function to unsubscribe from the exit handlers + */ +export function subscribeProcessExit( + options: ExitHandlerOptions = {}, +): () => void { // eslint-disable-next-line functional/no-let let closedReason: CloseReason | undefined; const { @@ -57,7 +69,7 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { onExit?.(code, reason); }; - process.on('uncaughtException', err => { + const uncaughtExceptionHandler = (err: unknown) => { onError?.(err, 'uncaughtException'); if (exitOnFatal) { close(fatalExitCode, { @@ -65,9 +77,9 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { fatal: 'uncaughtException', }); } - }); + }; - process.on('unhandledRejection', reason => { + const unhandledRejectionHandler = (reason: unknown) => { onError?.(reason, 'unhandledRejection'); if (exitOnFatal) { close(fatalExitCode, { @@ -75,22 +87,39 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { fatal: 'unhandledRejection', }); } - }); + }; - (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => { - process.on(signal, () => { - close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); - if (exitOnSignal) { - // eslint-disable-next-line n/no-process-exit - process.exit(SIGNAL_EXIT_CODES()[signal]); - } - }); - }); + const signalHandlers = (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).map( + signal => { + const handler = () => { + close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); + if (exitOnSignal) { + // eslint-disable-next-line unicorn/no-process-exit + process.exit(SIGNAL_EXIT_CODES()[signal]); + } + }; + process.on(signal, handler); + return { signal, handler }; + }, + ); - process.on('exit', code => { + const exitHandler = (code: number) => { if (closedReason) { return; } close(code, { kind: 'exit' }); - }); + }; + + process.on('uncaughtException', uncaughtExceptionHandler); + process.on('unhandledRejection', unhandledRejectionHandler); + process.on('exit', exitHandler); + + return () => { + process.removeListener('uncaughtException', uncaughtExceptionHandler); + process.removeListener('unhandledRejection', unhandledRejectionHandler); + process.removeListener('exit', exitHandler); + signalHandlers.forEach(({ signal, handler }) => { + process.removeListener(signal, handler); + }); + }; } diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts index d9437a51c..86ec51b59 100644 --- a/packages/utils/src/lib/exit-process.unit.test.ts +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -1,9 +1,9 @@ import os from 'node:os'; import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; +import { SIGNAL_EXIT_CODES, subscribeProcessExit } from './exit-process.js'; -describe('exit-process tests', () => { +describe('subscribeProcessExit', () => { const onError = vi.fn(); const onExit = vi.fn(); const processOnSpy = vi.spyOn(process, 'on'); @@ -27,7 +27,7 @@ describe('exit-process tests', () => { }); it('should install event listeners for all expected events', () => { - expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onError, onExit })).not.toThrow(); expect(processOnSpy).toHaveBeenCalledWith( 'uncaughtException', @@ -44,7 +44,7 @@ describe('exit-process tests', () => { }); it('should call onError with error and kind for uncaughtException', () => { - expect(() => installExitHandlers({ onError })).not.toThrow(); + expect(() => subscribeProcessExit({ onError })).not.toThrow(); const testError = new Error('Test uncaught exception'); @@ -56,7 +56,7 @@ describe('exit-process tests', () => { }); it('should call onError with reason and kind for unhandledRejection', () => { - expect(() => installExitHandlers({ onError })).not.toThrow(); + expect(() => subscribeProcessExit({ onError })).not.toThrow(); const testReason = 'Test unhandled rejection'; @@ -69,7 +69,7 @@ describe('exit-process tests', () => { it('should call onExit with correct code and reason for SIGINT', () => { expect(() => - installExitHandlers({ onExit, exitOnSignal: true }), + subscribeProcessExit({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGINT'); @@ -85,7 +85,7 @@ describe('exit-process tests', () => { it('should call onExit with correct code and reason for SIGTERM', () => { expect(() => - installExitHandlers({ onExit, exitOnSignal: true }), + subscribeProcessExit({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGTERM'); @@ -101,7 +101,7 @@ describe('exit-process tests', () => { it('should call onExit with correct code and reason for SIGQUIT', () => { expect(() => - installExitHandlers({ onExit, exitOnSignal: true }), + subscribeProcessExit({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGQUIT'); @@ -117,7 +117,7 @@ describe('exit-process tests', () => { it('should not exit process when exitOnSignal is false', () => { expect(() => - installExitHandlers({ onExit, exitOnSignal: false }), + subscribeProcessExit({ onExit, exitOnSignal: false }), ).not.toThrow(); (process as any).emit('SIGINT'); @@ -132,7 +132,7 @@ describe('exit-process tests', () => { }); it('should not exit process when exitOnSignal is not set', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); (process as any).emit('SIGTERM'); @@ -146,7 +146,7 @@ describe('exit-process tests', () => { }); it('should call onExit with exit code and reason for normal exit', () => { - expect(() => installExitHandlers({ onExit })).not.toThrow(); + expect(() => subscribeProcessExit({ onExit })).not.toThrow(); const exitCode = 42; (process as any).emit('exit', exitCode); @@ -159,7 +159,7 @@ describe('exit-process tests', () => { it('should call onExit with fatal reason when exitOnFatal is true', () => { expect(() => - installExitHandlers({ onError, onExit, exitOnFatal: true }), + subscribeProcessExit({ onError, onExit, exitOnFatal: true }), ).not.toThrow(); const testError = new Error('Test uncaught exception'); @@ -177,7 +177,7 @@ describe('exit-process tests', () => { it('should use custom fatalExitCode when exitOnFatal is true', () => { expect(() => - installExitHandlers({ + subscribeProcessExit({ onError, onExit, exitOnFatal: true, @@ -200,7 +200,7 @@ describe('exit-process tests', () => { it('should call onExit with fatal reason for unhandledRejection when exitOnFatal is true', () => { expect(() => - installExitHandlers({ onError, onExit, exitOnFatal: true }), + subscribeProcessExit({ onError, onExit, exitOnFatal: true }), ).not.toThrow(); const testReason = 'Test unhandled rejection'; @@ -244,7 +244,7 @@ describe('exit-process tests', () => { it('should call onExit only once even when close is called multiple times', () => { expect(() => - installExitHandlers({ onExit, exitOnSignal: true }), + subscribeProcessExit({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGINT'); diff --git a/packages/utils/src/lib/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 130e28c44..90f5b9e06 100644 --- a/packages/utils/src/lib/profiler/profiler.ts +++ b/packages/utils/src/lib/profiler/profiler.ts @@ -1,10 +1,13 @@ import process from 'node:process'; import { isEnvVarEnabled } from '../env.js'; +import { subscribeProcessExit } from '../exit-process.js'; +import type { TraceEvent } from '../trace-file.type'; import { type ActionTrackConfigs, type MeasureCtxOptions, type MeasureOptions, asOptions, + errorToMarkerPayload, markerPayload, measureCtx, setupTracks, @@ -226,3 +229,87 @@ export class Profiler { } } } + +// @TODO implement ShardedWAL +type WalSink = { + append: (event: TraceEvent) => void; + open: () => void; + close: () => void; + isClosed: () => boolean; +}; + +export type NodeJsProfilerOptions = + ProfilerOptions & { + // @TODO implement WALFormat + format: { + encode: (v: string | object) => string; + }; + }; + +export class NodeJsProfiler extends Profiler { + #exitHandlerSubscribscription: null | (() => void) = null; + protected sink: WalSink | null = null; + + constructor(options: NodeJsProfilerOptions) { + super(options); + // Temporary dummy sink; replaced by real WAL implementation + this.sink = { + append: event => { + options.format.encode(event); + }, + open: () => void 0, + close: () => void 0, + isClosed: () => false, + }; + this.#exitHandlerSubscribscription = this.subscribeProcessExit(); + } + + /** + * Installs process exit and error handlers to ensure proper cleanup of profiling resources. + * + * When an error occurs or the process exits, this automatically creates a fatal error marker + * and shuts down the profiler gracefully, ensuring all buffered data is flushed. + * + * @protected + */ + protected subscribeProcessExit(): () => void { + return subscribeProcessExit({ + onError: (err, kind) => { + if (!super.isEnabled()) { + return; + } + this.marker('Fatal Error', { + ...errorToMarkerPayload(err), + tooltipText: `${kind} caused fatal error`, + }); + this.close(); + }, + onExit: (code, reason) => { + if (!super.isEnabled()) { + return; + } + this.marker('Process Exit', { + ...(code === 0 ? {} : { color: 'warning' }), + properties: [['reason', JSON.stringify(reason)]], + tooltipText: `Process exited with code ${code}`, + }); + this.close(); + }, + }); + } + + /** + * Closes the profiler and releases all associated resources. + * Profiling is finished forever for this instance. + * + * This method should be called when profiling is complete to ensure all buffered + * data is flushed and the WAL sink is properly closed. + */ + close(): void { + if (!this.isEnabled()) { + return; + } + this.setEnabled(false); + this.#exitHandlerSubscribscription?.(); + } +} diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0e285deb2..24f3f8ead 100644 --- a/packages/utils/src/lib/profiler/profiler.unit.test.ts +++ b/packages/utils/src/lib/profiler/profiler.unit.test.ts @@ -1,7 +1,11 @@ import { performance } from 'node:perf_hooks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { subscribeProcessExit } from '../exit-process.js'; import type { ActionTrackEntryPayload } from '../user-timing-extensibility-api.type.js'; -import { Profiler, type ProfilerOptions } from './profiler.js'; +import { NodeJsProfiler, Profiler, type ProfilerOptions } from './profiler.js'; + +// Spy on subscribeProcessExit to capture handlers +vi.mock('../exit-process.js'); describe('Profiler', () => { const getProfiler = (overrides?: Partial) => @@ -424,3 +428,134 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); }); + +describe('NodeJsProfiler', () => { + const mockSubscribeProcessExit = vi.mocked(subscribeProcessExit); + + let capturedOnError: + | (( + error: unknown, + kind: 'uncaughtException' | 'unhandledRejection', + ) => void) + | undefined; + let capturedOnExit: + | ((code: number, reason: import('../exit-process.js').CloseReason) => void) + | undefined; + const createProfiler = (overrides?: Partial) => + new NodeJsProfiler({ + prefix: 'cp', + track: 'test-track', + format: { + encode: v => JSON.stringify(v), + }, + ...overrides, + }); + + let profiler: NodeJsProfiler>; + + beforeEach(() => { + capturedOnError = undefined; + capturedOnExit = undefined; + + mockSubscribeProcessExit.mockImplementation(options => { + capturedOnError = options?.onError; + capturedOnExit = options?.onExit; + return vi.fn(); + }); + + performance.clearMarks(); + performance.clearMeasures(); + // eslint-disable-next-line functional/immutable-data + delete process.env.CP_PROFILING; + }); + + it('installs exit handlers on construction', () => { + expect(() => createProfiler()).not.toThrow(); + + expect(mockSubscribeProcessExit).toHaveBeenCalledWith({ + onError: expect.any(Function), + onExit: expect.any(Function), + }); + }); + + it('setEnabled toggles profiler state', () => { + profiler = createProfiler({ enabled: true }); + expect(profiler.isEnabled()).toBe(true); + + profiler.setEnabled(false); + expect(profiler.isEnabled()).toBe(false); + + profiler.setEnabled(true); + expect(profiler.isEnabled()).toBe(true); + }); + + it('marks fatal errors and shuts down profiler on uncaughtException', () => { + profiler = createProfiler({ enabled: true }); + + const testError = new Error('Test fatal error'); + capturedOnError?.call(profiler, testError, 'uncaughtException'); + + expect(performance.getEntriesByType('mark')).toStrictEqual([ + { + name: 'Fatal Error', + detail: { + devtools: { + color: 'error', + dataType: 'marker', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'Test fatal error'], + ], + tooltipText: 'uncaughtException caused fatal error', + }, + }, + duration: 0, + entryType: 'mark', + startTime: 0, + }, + ]); + }); + + it('marks fatal errors and shuts down profiler on unhandledRejection', () => { + profiler = createProfiler({ enabled: true }); + expect(profiler.isEnabled()).toBe(true); + + capturedOnError?.call( + profiler, + new Error('Test fatal error'), + 'unhandledRejection', + ); + + expect(performance.getEntriesByType('mark')).toStrictEqual([ + { + name: 'Fatal Error', + detail: { + devtools: { + color: 'error', + dataType: 'marker', + properties: [ + ['Error Type', 'Error'], + ['Error Message', 'Test fatal error'], + ], + tooltipText: 'unhandledRejection caused fatal error', + }, + }, + duration: 0, + entryType: 'mark', + startTime: 0, + }, + ]); + }); + + it('exit handler shuts down profiler', () => { + profiler = createProfiler({ enabled: true }); + const setEnabledSpy = vi.spyOn(profiler, 'setEnabled'); + expect(profiler.isEnabled()).toBe(true); + + capturedOnExit?.(0, { kind: 'exit' }); + + expect(profiler.isEnabled()).toBe(false); + expect(setEnabledSpy).toHaveBeenCalledTimes(1); + expect(setEnabledSpy).toHaveBeenCalledWith(false); + }); +});