Skip to content

Commit 00b97a2

Browse files
authored
fix: add exit handler to profiler (#1221)
1 parent e47bf34 commit 00b97a2

21 files changed

+2192
-1365
lines changed

packages/utils/docs/profiler.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
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.
88

9+
### Features
10+
11+
- **Type-Safe API**: Fully typed UserTiming API for [Chrome DevTools Extensibility API](https://developer.chrome.com/docs/devtools/performance/extension)
12+
- **Measure API**: Easy-to-use methods for measuring synchronous and asynchronous code execution times.
13+
- **Custom Track Configuration**: Fully typed reusable configurations for custom track visualization.
14+
- **Process buffered entries**: Captures and processes buffered profiling entries.
15+
- **3rd Party Profiling**: Automatically processes third-party performance entries.
16+
- **Clean measure names**: Automatically adds prefixes to measure names, as well as start/end postfix to marks, for better organization.
17+
918
## Getting started
1019

1120
1. If you haven't already, install [@code-pushup/utils](../../README.md).
@@ -257,10 +266,23 @@ const saved = profiler.measure('save-user', () => saveToDb(user), {
257266

258267
## NodeJSProfiler
259268

260-
This profiler extends all options and API from Profiler with automatic process exit handling for buffered performance data.
269+
### Features
270+
271+
- **Crash-safe Write Ahead Log**: Ensures profiling data is saved even if the application crashes.
272+
- **Recoverable Profiles**: Ability to resume profiling sessions after interruptions or crash.
273+
- **Automatic Trace Generation**: Generates trace files compatible with Chrome DevTools for in-depth performance analysis.
274+
- **Multiprocess Support**: Designed to handle profiling over sharded WAL.
275+
- **Controllable over env vars**: Easily enable or disable profiling through environment variables.
261276

277+
This profiler extends all options and API from Profiler with automatic process exit handling for buffered performance data.
262278
The NodeJSProfiler automatically subscribes to performance observation and installs exit handlers that flush buffered data on process termination (signals, fatal errors, or normal exit).
263279

280+
### Exit Handlers
281+
282+
The profiler automatically subscribes to process events (`exit`, `SIGINT`, `SIGTERM`, `SIGQUIT`, `uncaughtException`, `unhandledRejection`) during construction. When any of these occur, the handlers call `close()` to ensure buffered data is flushed.
283+
284+
The `close()` method is idempotent and safe to call from exit handlers. It unsubscribes from exit handlers, closes the WAL sink, and unsubscribes from the performance observer, ensuring all buffered performance data is written before process termination.
285+
264286
## Configuration
265287

266288
```ts

packages/utils/mocks/sink.mock.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,8 @@ export class MockTraceEventFileSink extends MockAppendableSink {
5151
repack = vi.fn((): void => {});
5252

5353
finalize = vi.fn((): void => {});
54+
55+
getPath = vi.fn((): string => {
56+
return '/test/tmp/profiles/default/trace.default.jsonl';
57+
});
5458
}

packages/utils/src/lib/exit-process.int.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import process from 'node:process';
2-
import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js';
2+
import { SIGNAL_EXIT_CODES, subscribeProcessExit } from './exit-process.js';
33

4-
describe('installExitHandlers', () => {
4+
describe('subscribeProcessExit', () => {
55
const onError = vi.fn();
66
const onExit = vi.fn();
77
const processOnSpy = vi.spyOn(process, 'on');
@@ -25,7 +25,7 @@ describe('installExitHandlers', () => {
2525
});
2626

2727
it('should install event listeners for all expected events', () => {
28-
expect(() => installExitHandlers({ onError, onExit })).not.toThrow();
28+
expect(() => subscribeProcessExit({ onError, onExit })).not.toThrow();
2929

3030
expect(processOnSpy).toHaveBeenCalledWith(
3131
'uncaughtException',
@@ -42,7 +42,7 @@ describe('installExitHandlers', () => {
4242
});
4343

4444
it('should call onError with error and kind for uncaughtException', () => {
45-
expect(() => installExitHandlers({ onError })).not.toThrow();
45+
expect(() => subscribeProcessExit({ onError })).not.toThrow();
4646

4747
const testError = new Error('Test uncaught exception');
4848

@@ -54,7 +54,7 @@ describe('installExitHandlers', () => {
5454
});
5555

5656
it('should call onError with reason and kind for unhandledRejection', () => {
57-
expect(() => installExitHandlers({ onError })).not.toThrow();
57+
expect(() => subscribeProcessExit({ onError })).not.toThrow();
5858

5959
const testReason = 'Test unhandled rejection';
6060

@@ -66,7 +66,7 @@ describe('installExitHandlers', () => {
6666
});
6767

6868
it('should call onExit and exit with code 0 for SIGINT', () => {
69-
expect(() => installExitHandlers({ onExit })).not.toThrow();
69+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
7070

7171
(process as any).emit('SIGINT');
7272

@@ -79,7 +79,7 @@ describe('installExitHandlers', () => {
7979
});
8080

8181
it('should call onExit and exit with code 0 for SIGTERM', () => {
82-
expect(() => installExitHandlers({ onExit })).not.toThrow();
82+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
8383

8484
(process as any).emit('SIGTERM');
8585

@@ -92,7 +92,7 @@ describe('installExitHandlers', () => {
9292
});
9393

9494
it('should call onExit and exit with code 0 for SIGQUIT', () => {
95-
expect(() => installExitHandlers({ onExit })).not.toThrow();
95+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
9696

9797
(process as any).emit('SIGQUIT');
9898

@@ -105,7 +105,7 @@ describe('installExitHandlers', () => {
105105
});
106106

107107
it('should call onExit for successful process termination with exit code 0', () => {
108-
expect(() => installExitHandlers({ onExit })).not.toThrow();
108+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
109109

110110
(process as any).emit('exit', 0);
111111

@@ -116,7 +116,7 @@ describe('installExitHandlers', () => {
116116
});
117117

118118
it('should call onExit for failed process termination with exit code 1', () => {
119-
expect(() => installExitHandlers({ onExit })).not.toThrow();
119+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
120120

121121
(process as any).emit('exit', 1);
122122

packages/utils/src/lib/exit-process.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,20 @@ export type ExitHandlerOptions = {
3838
fatalExitCode?: number;
3939
};
4040

41-
export function installExitHandlers(options: ExitHandlerOptions = {}): void {
41+
/**
42+
*
43+
* @param options - Options for the exit handler
44+
* @param options.onExit - Callback to be called when the process exits
45+
* @param options.onError - Callback to be called when an error occurs
46+
* @param options.exitOnFatal - Whether to exit the process on fatal errors
47+
* @param options.exitOnSignal - Whether to exit the process on signals
48+
* @param options.fatalExitCode - The exit code to use for fatal errors
49+
* @returns A function to unsubscribe from the exit handlers
50+
*/
51+
// eslint-disable-next-line max-lines-per-function
52+
export function subscribeProcessExit(
53+
options: ExitHandlerOptions = {},
54+
): () => void {
4255
// eslint-disable-next-line functional/no-let
4356
let closedReason: CloseReason | undefined;
4457
const {
@@ -57,40 +70,57 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void {
5770
onExit?.(code, reason);
5871
};
5972

60-
process.on('uncaughtException', err => {
73+
const uncaughtExceptionHandler = (err: unknown) => {
6174
onError?.(err, 'uncaughtException');
6275
if (exitOnFatal) {
6376
close(fatalExitCode, {
6477
kind: 'fatal',
6578
fatal: 'uncaughtException',
6679
});
6780
}
68-
});
81+
};
6982

70-
process.on('unhandledRejection', reason => {
83+
const unhandledRejectionHandler = (reason: unknown) => {
7184
onError?.(reason, 'unhandledRejection');
7285
if (exitOnFatal) {
7386
close(fatalExitCode, {
7487
kind: 'fatal',
7588
fatal: 'unhandledRejection',
7689
});
7790
}
78-
});
91+
};
7992

80-
(['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => {
81-
process.on(signal, () => {
82-
close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal });
83-
if (exitOnSignal) {
84-
// eslint-disable-next-line n/no-process-exit
85-
process.exit(SIGNAL_EXIT_CODES()[signal]);
86-
}
87-
});
88-
});
93+
const signalHandlers = (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).map(
94+
signal => {
95+
const handler = () => {
96+
close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal });
97+
if (exitOnSignal) {
98+
// eslint-disable-next-line unicorn/no-process-exit,n/no-process-exit
99+
process.exit(SIGNAL_EXIT_CODES()[signal]);
100+
}
101+
};
102+
process.on(signal, handler);
103+
return { signal, handler };
104+
},
105+
);
89106

90-
process.on('exit', code => {
107+
const exitHandler = (code: number) => {
91108
if (closedReason) {
92109
return;
93110
}
94111
close(code, { kind: 'exit' });
95-
});
112+
};
113+
114+
process.on('uncaughtException', uncaughtExceptionHandler);
115+
process.on('unhandledRejection', unhandledRejectionHandler);
116+
process.on('exit', exitHandler);
117+
118+
return () => {
119+
process.removeListener('uncaughtException', uncaughtExceptionHandler);
120+
process.removeListener('unhandledRejection', unhandledRejectionHandler);
121+
process.removeListener('exit', exitHandler);
122+
signalHandlers.forEach(({ signal, handler }) => {
123+
process.removeListener(signal, handler);
124+
});
125+
};
96126
}

packages/utils/src/lib/exit-process.unit.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import os from 'node:os';
22
import process from 'node:process';
3-
import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js';
3+
import { SIGNAL_EXIT_CODES, subscribeProcessExit } from './exit-process.js';
44

5-
describe('exit-process tests', () => {
5+
describe('subscribeProcessExit', () => {
66
const onError = vi.fn();
77
const onExit = vi.fn();
88
const processOnSpy = vi.spyOn(process, 'on');
@@ -26,7 +26,7 @@ describe('exit-process tests', () => {
2626
});
2727

2828
it('should install event listeners for all expected events', () => {
29-
expect(() => installExitHandlers({ onError, onExit })).not.toThrow();
29+
expect(() => subscribeProcessExit({ onError, onExit })).not.toThrow();
3030

3131
expect(processOnSpy).toHaveBeenCalledWith(
3232
'uncaughtException',
@@ -43,7 +43,7 @@ describe('exit-process tests', () => {
4343
});
4444

4545
it('should call onError with error and kind for uncaughtException', () => {
46-
expect(() => installExitHandlers({ onError })).not.toThrow();
46+
expect(() => subscribeProcessExit({ onError })).not.toThrow();
4747

4848
const testError = new Error('Test uncaught exception');
4949

@@ -55,7 +55,7 @@ describe('exit-process tests', () => {
5555
});
5656

5757
it('should call onError with reason and kind for unhandledRejection', () => {
58-
expect(() => installExitHandlers({ onError })).not.toThrow();
58+
expect(() => subscribeProcessExit({ onError })).not.toThrow();
5959

6060
const testReason = 'Test unhandled rejection';
6161

@@ -68,7 +68,7 @@ describe('exit-process tests', () => {
6868

6969
it('should call onExit with correct code and reason for SIGINT', () => {
7070
expect(() =>
71-
installExitHandlers({ onExit, exitOnSignal: true }),
71+
subscribeProcessExit({ onExit, exitOnSignal: true }),
7272
).not.toThrow();
7373

7474
(process as any).emit('SIGINT');
@@ -84,7 +84,7 @@ describe('exit-process tests', () => {
8484

8585
it('should call onExit with correct code and reason for SIGTERM', () => {
8686
expect(() =>
87-
installExitHandlers({ onExit, exitOnSignal: true }),
87+
subscribeProcessExit({ onExit, exitOnSignal: true }),
8888
).not.toThrow();
8989

9090
(process as any).emit('SIGTERM');
@@ -100,7 +100,7 @@ describe('exit-process tests', () => {
100100

101101
it('should call onExit with correct code and reason for SIGQUIT', () => {
102102
expect(() =>
103-
installExitHandlers({ onExit, exitOnSignal: true }),
103+
subscribeProcessExit({ onExit, exitOnSignal: true }),
104104
).not.toThrow();
105105

106106
(process as any).emit('SIGQUIT');
@@ -116,7 +116,7 @@ describe('exit-process tests', () => {
116116

117117
it('should not exit process when exitOnSignal is false', () => {
118118
expect(() =>
119-
installExitHandlers({ onExit, exitOnSignal: false }),
119+
subscribeProcessExit({ onExit, exitOnSignal: false }),
120120
).not.toThrow();
121121

122122
(process as any).emit('SIGINT');
@@ -131,7 +131,7 @@ describe('exit-process tests', () => {
131131
});
132132

133133
it('should not exit process when exitOnSignal is not set', () => {
134-
expect(() => installExitHandlers({ onExit })).not.toThrow();
134+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
135135

136136
(process as any).emit('SIGTERM');
137137

@@ -145,7 +145,7 @@ describe('exit-process tests', () => {
145145
});
146146

147147
it('should call onExit with exit code and reason for normal exit', () => {
148-
expect(() => installExitHandlers({ onExit })).not.toThrow();
148+
expect(() => subscribeProcessExit({ onExit })).not.toThrow();
149149

150150
const exitCode = 42;
151151
(process as any).emit('exit', exitCode);
@@ -158,7 +158,7 @@ describe('exit-process tests', () => {
158158

159159
it('should call onExit with fatal reason when exitOnFatal is true', () => {
160160
expect(() =>
161-
installExitHandlers({ onError, onExit, exitOnFatal: true }),
161+
subscribeProcessExit({ onError, onExit, exitOnFatal: true }),
162162
).not.toThrow();
163163

164164
const testError = new Error('Test uncaught exception');
@@ -176,7 +176,7 @@ describe('exit-process tests', () => {
176176

177177
it('should use custom fatalExitCode when exitOnFatal is true', () => {
178178
expect(() =>
179-
installExitHandlers({
179+
subscribeProcessExit({
180180
onError,
181181
onExit,
182182
exitOnFatal: true,
@@ -199,7 +199,7 @@ describe('exit-process tests', () => {
199199

200200
it('should call onExit with fatal reason for unhandledRejection when exitOnFatal is true', () => {
201201
expect(() =>
202-
installExitHandlers({ onError, onExit, exitOnFatal: true }),
202+
subscribeProcessExit({ onError, onExit, exitOnFatal: true }),
203203
).not.toThrow();
204204

205205
const testReason = 'Test unhandled rejection';
@@ -243,7 +243,7 @@ describe('exit-process tests', () => {
243243

244244
it('should call onExit only once even when close is called multiple times', () => {
245245
expect(() =>
246-
installExitHandlers({ onExit, exitOnSignal: true }),
246+
subscribeProcessExit({ onExit, exitOnSignal: true }),
247247
).not.toThrow();
248248

249249
(process as any).emit('SIGINT');
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-1:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
2+
{"cat":"blink.user_timing","ph":"b","name":"stats-profiler:operation-1","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
3+
{"cat":"blink.user_timing","ph":"e","name":"stats-profiler:operation-1","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
4+
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-1:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
5+
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-2:start","pid":10001,"tid":1,"ts":1700000005000004,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
6+
{"cat":"blink.user_timing","ph":"b","name":"stats-profiler:operation-2","id2":{"local":"0x2"},"pid":10001,"tid":1,"ts":1700000005000005,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
7+
{"cat":"blink.user_timing","ph":"e","name":"stats-profiler:operation-2","id2":{"local":"0x2"},"pid":10001,"tid":1,"ts":1700000005000006,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
8+
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-2:end","pid":10001,"tid":1,"ts":1700000005000007,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"cat":"blink.user_timing","ph":"i","name":"api-server:user-lookup:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
2+
{"cat":"blink.user_timing","ph":"b","name":"api-server:user-lookup","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
3+
{"cat":"blink.user_timing","ph":"e","name":"api-server:user-lookup","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
4+
{"cat":"blink.user_timing","ph":"i","name":"api-server:user-lookup:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"cat":"blink.user_timing","ph":"i","name":"write-test:test-operation:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}
2+
{"cat":"blink.user_timing","ph":"b","name":"write-test:test-operation","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
3+
{"cat":"blink.user_timing","ph":"e","name":"write-test:test-operation","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
4+
{"cat":"blink.user_timing","ph":"i","name":"write-test:test-operation:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}

0 commit comments

Comments
 (0)