Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/utils/docs/profiler.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 102 additions & 0 deletions packages/utils/src/lib/profiler/profiler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import process from 'node:process';
import { isEnvVarEnabled } from '../env.js';
import { installExitHandlers } from '../exit-process';

Check failure on line 3 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | enforce the style of file extensions in `import` declarations

require file extension '.js'.
import type { TraceEvent } from '../trace-file.type';
import {
type ActionTrackConfigs,
type MeasureCtxOptions,
type MeasureOptions,
asOptions,
errorToMarkerPayload,
markerPayload,
measureCtx,
setupTracks,
Expand All @@ -13,6 +16,7 @@
ActionTrackEntryPayload,
DevToolsColor,
EntryMeta,
UserTimingDetail,

Check failure on line 19 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Disallow unused variables

'UserTimingDetail' is defined but never used.
} from '../user-timing-extensibility-api.type.js';
import { PROFILER_ENABLED_ENV_VAR } from './constants.js';

Expand Down Expand Up @@ -226,3 +230,101 @@
}
}
}

// @TODO implement ShardedWAL
type WalSink = {
append(event: TraceEvent): void;

Check warning on line 236 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Prefer property signatures over method signatures.

Use a property signature instead of a method signature

Check warning on line 236 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce using a particular method signature syntax

Shorthand method signature is forbidden. Use a function property instead.
open(): void;

Check warning on line 237 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Prefer property signatures over method signatures.

Use a property signature instead of a method signature

Check warning on line 237 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce using a particular method signature syntax

Shorthand method signature is forbidden. Use a function property instead.
close(): void;

Check warning on line 238 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Prefer property signatures over method signatures.

Use a property signature instead of a method signature

Check warning on line 238 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce using a particular method signature syntax

Shorthand method signature is forbidden. Use a function property instead.
};

export type NodeJsProfilerOptions<T extends ActionTrackConfigs> =
ProfilerOptions<T> & {
// @TODO implement WALFormat
format: {
encode(v: string | object): string;

Check warning on line 245 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce using a particular method signature syntax

Shorthand method signature is forbidden. Use a function property instead.
};
};

export class NodeJsProfiler<T extends ActionTrackConfigs> extends Profiler<T> {
protected sink: WalSink | null = null;

constructor(options: NodeJsProfilerOptions<T>) {
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();

Check warning on line 296 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Disallow unused expressions

Expected an assignment or function call and instead saw an expression.
}

/**
* 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 {

Check warning on line 313 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce that class methods utilize `this`

Expected 'this' to be used by class method 'flush'.
// @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;

Check warning on line 326 in packages/utils/src/lib/profiler/profiler.ts

View workflow job for this annotation

GitHub Actions / Standalone mode

<✓> ESLint | Enforce consistent brace style for all control statements

Expected { after 'if' condition.
this.flush();
this.setEnabled(false);
}
}
179 changes: 178 additions & 1 deletion packages/utils/src/lib/profiler/profiler.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProfilerOptions>) =>
Expand Down Expand Up @@ -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<ProfilerOptions>) =>
new NodeJsProfiler({
prefix: 'cp',
track: 'test-track',
format: {
encode: v => JSON.stringify(v),
},
...overrides,
});

let profiler: NodeJsProfiler<Record<string, ActionTrackEntryPayload>>;

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();
});
});
Loading