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/profiler/profiler.ts b/packages/utils/src/lib/profiler/profiler.ts index 130e28c44..b3748b4a9 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 { installExitHandlers } from '../exit-process'; +import type { TraceEvent } from '../trace-file.type'; import { type ActionTrackConfigs, type MeasureCtxOptions, type MeasureOptions, asOptions, + errorToMarkerPayload, markerPayload, measureCtx, setupTracks, @@ -13,6 +16,7 @@ import type { ActionTrackEntryPayload, DevToolsColor, EntryMeta, + UserTimingDetail, } from '../user-timing-extensibility-api.type.js'; import { PROFILER_ENABLED_ENV_VAR } from './constants.js'; @@ -226,3 +230,101 @@ export class Profiler { } } } + +// @TODO implement ShardedWAL +type WalSink = { + append(event: TraceEvent): void; + open(): void; + close(): void; +}; + +export type NodeJsProfilerOptions = + ProfilerOptions & { + // @TODO implement WALFormat + format: { + encode(v: string | object): string; + }; + }; + +export class NodeJsProfiler extends Profiler { + 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, + }; + this.installExitHandlers(); + } + + /** + * 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 installExitHandlers(): void { + installExitHandlers({ + onError: (err, kind) => { + if (!this.isEnabled()) { + return; + } + this.marker('Fatal Error', { + ...errorToMarkerPayload(err), + tooltipText: `${kind} caused fatal error`, + }); + this.shutdown(); + }, + onExit: () => { + if (!this.isEnabled()) { + return; + } + this.shutdown(); + }, + }); + } + + override setEnabled(enabled: boolean): void { + super.setEnabled(enabled); + enabled ? this.sink?.open() : this.sink?.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 { + this.shutdown(); + } + + /** + * Forces all buffered Performance Entries to be written to the WAL sink. + */ + flush(): void { + // @TODO implement WAL flush, currently all entries are buffered in memory + } + + /** + * Performs internal cleanup of profiling resources. + * + * Flushes any remaining buffered data and closes the WAL sink. + * This method is called automatically on process exit or error. + * + * @protected + */ + protected shutdown(): void { + if (!this.isEnabled()) return; + this.flush(); + this.setEnabled(false); + } +} diff --git a/packages/utils/src/lib/profiler/profiler.unit.test.ts b/packages/utils/src/lib/profiler/profiler.unit.test.ts index 0e285deb2..6039343ac 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 { installExitHandlers } 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 installExitHandlers to capture handlers +vi.mock('../exit-process.js'); describe('Profiler', () => { const getProfiler = (overrides?: Partial) => @@ -424,3 +428,176 @@ describe('Profiler', () => { expect(workFn).toHaveBeenCalled(); }); }); +describe('NodeJsProfiler', () => { + const mockInstallExitHandlers = vi.mocked(installExitHandlers); + + 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; + + mockInstallExitHandlers.mockImplementation(options => { + capturedOnError = options?.onError; + capturedOnExit = options?.onExit; + }); + + 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(mockInstallExitHandlers).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('shutdown method shuts down profiler', () => { + profiler = createProfiler({ enabled: true }); + const setEnabledSpy = vi.spyOn(profiler, 'setEnabled'); + const sinkCloseSpy = vi.spyOn((profiler as any).sink, 'close'); + expect(profiler.isEnabled()).toBe(true); + + (profiler as any).shutdown(); + + expect(setEnabledSpy).toHaveBeenCalledTimes(1); + expect(setEnabledSpy).toHaveBeenCalledWith(false); + expect(sinkCloseSpy).toHaveBeenCalledTimes(1); + expect(profiler.isEnabled()).toBe(false); + }); + it('exit handler shuts down profiler', () => { + profiler = createProfiler({ enabled: true }); + const shutdownSpy = vi.spyOn(profiler, 'shutdown' as any); + expect(profiler.isEnabled()).toBe(true); + + capturedOnExit?.(0, { kind: 'exit' }); + + expect(profiler.isEnabled()).toBe(false); + expect(shutdownSpy).toHaveBeenCalledTimes(1); + }); + + it('close method shuts down profiler', () => { + profiler = createProfiler({ enabled: true }); + const shutdownSpy = vi.spyOn(profiler, 'shutdown' as any); + expect(profiler.isEnabled()).toBe(true); + + profiler.close(); + + expect(shutdownSpy).toHaveBeenCalledTimes(1); + expect(profiler.isEnabled()).toBe(false); + }); + + it('error handler does nothing when profiler is disabled', () => { + profiler = createProfiler({ enabled: false }); // Start disabled + expect(profiler.isEnabled()).toBe(false); + + const testError = new Error('Test error'); + capturedOnError?.call(profiler, testError, 'uncaughtException'); + + // Should not create any marks when disabled + expect(performance.getEntriesByType('mark')).toHaveLength(0); + }); + + it('exit handler does nothing when profiler is disabled', () => { + profiler = createProfiler({ enabled: false }); // Start disabled + expect(profiler.isEnabled()).toBe(false); + + // Should not call shutdown when disabled + const shutdownSpy = vi.spyOn(profiler, 'shutdown' as any); + capturedOnExit?.(0, { kind: 'exit' }); + + expect(shutdownSpy).not.toHaveBeenCalled(); + }); +});