@mtharrison/pkg-profiler 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sampler.ts CHANGED
@@ -1,21 +1,130 @@
1
- import { Session } from 'node:inspector/promises';
2
- import type { Profiler } from 'node:inspector';
3
- import { parseFrame } from './frame-parser.js';
4
- import { PackageResolver } from './package-resolver.js';
5
- import { generateReport } from './reporter.js';
6
- import { SampleStore } from './sample-store.js';
7
- import type { RawCallFrame } from './types.js';
1
+ import { readFileSync } from "node:fs";
2
+ import type { Profiler } from "node:inspector";
3
+ import { Session } from "node:inspector";
4
+ import { join } from "node:path";
5
+ import { AsyncTracker } from "./async-tracker.js";
6
+ import { parseFrame } from "./frame-parser.js";
7
+ import { PackageResolver } from "./package-resolver.js";
8
+ import { PkgProfile } from "./pkg-profile.js";
9
+ import { aggregate } from "./reporter/aggregate.js";
10
+ import { SampleStore } from "./sample-store.js";
11
+ import type {
12
+ ProfileCallbackOptions,
13
+ RawCallFrame,
14
+ StartOptions,
15
+ } from "./types.js";
8
16
 
9
17
  // Module-level state -- lazy initialization
10
18
  let session: Session | null = null;
11
19
  let profiling = false;
20
+ let startHrtime: [number, number] | null = null;
12
21
  const store = new SampleStore();
22
+ const asyncStore = new SampleStore();
13
23
  const resolver = new PackageResolver(process.cwd());
24
+ let asyncTracker: AsyncTracker | null = null;
25
+
26
+ /**
27
+ * Promisify session.post for the normal async API path.
28
+ */
29
+ function postAsync(method: string, params?: object): Promise<any> {
30
+ return new Promise<any>((resolve, reject) => {
31
+ const cb: any = (err: Error | null, result?: any) => {
32
+ if (err) reject(err);
33
+ else resolve(result);
34
+ };
35
+ if (params !== undefined) {
36
+ session!.post(method, params, cb);
37
+ } else {
38
+ session!.post(method, cb);
39
+ }
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Synchronous session.post — works because the V8 inspector executes
45
+ * callbacks synchronously for in-process sessions.
46
+ */
47
+ function postSync(method: string): any {
48
+ let result: any;
49
+ let error: Error | null = null;
50
+ const cb: any = (err: Error | null, params?: any) => {
51
+ error = err;
52
+ result = params;
53
+ };
54
+ session!.post(method, cb);
55
+ if (error) throw error;
56
+ return result;
57
+ }
58
+
59
+ function readProjectName(cwd: string): string {
60
+ try {
61
+ const raw = readFileSync(join(cwd, "package.json"), "utf-8");
62
+ const pkg = JSON.parse(raw) as { name?: string };
63
+ return pkg.name ?? "app";
64
+ } catch {
65
+ return "app";
66
+ }
67
+ }
68
+
69
+ function buildEmptyProfile(): PkgProfile {
70
+ const projectName = readProjectName(process.cwd());
71
+ return new PkgProfile({
72
+ timestamp: new Date().toLocaleString(),
73
+ totalTimeUs: 0,
74
+ packages: [],
75
+ otherCount: 0,
76
+ projectName,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Shared logic for stopping the profiler and building a PkgProfile.
82
+ * Synchronous — safe to call from process `exit` handlers.
83
+ */
84
+ function stopSync(): PkgProfile {
85
+ if (!profiling || !session) {
86
+ return buildEmptyProfile();
87
+ }
88
+
89
+ const elapsed = startHrtime ? process.hrtime(startHrtime) : null;
90
+ const wallTimeUs = elapsed ? elapsed[0] * 1_000_000 + Math.round(elapsed[1] / 1000) : undefined;
91
+ startHrtime = null;
92
+
93
+ const { profile } = postSync("Profiler.stop") as Profiler.StopReturnType;
94
+ postSync("Profiler.disable");
95
+ profiling = false;
96
+
97
+ let globalAsyncTimeUs: number | undefined;
98
+ if (asyncTracker) {
99
+ asyncTracker.disable();
100
+ globalAsyncTimeUs = asyncTracker.mergedTotalUs;
101
+ asyncTracker = null;
102
+ }
103
+
104
+ processProfile(profile);
105
+
106
+ const projectName = readProjectName(process.cwd());
107
+ const data = aggregate(
108
+ store,
109
+ projectName,
110
+ asyncStore.packages.size > 0 ? asyncStore : undefined,
111
+ globalAsyncTimeUs,
112
+ wallTimeUs,
113
+ );
114
+ store.clear();
115
+ asyncStore.clear();
116
+
117
+ return new PkgProfile(data);
118
+ }
14
119
 
15
120
  /**
16
121
  * Start the V8 CPU profiler. If already profiling, this is a safe no-op.
122
+ *
123
+ * @param options - Optional configuration.
124
+ * @param options.interval - Sampling interval in microseconds passed to V8 (defaults to 1000µs). Lower values = higher fidelity but more overhead.
125
+ * @returns Resolves when the profiler is successfully started
17
126
  */
18
- export async function track(options?: { interval?: number }): Promise<void> {
127
+ export async function start(options?: StartOptions): Promise<void> {
19
128
  if (profiling) return;
20
129
 
21
130
  if (session === null) {
@@ -23,16 +132,32 @@ export async function track(options?: { interval?: number }): Promise<void> {
23
132
  session.connect();
24
133
  }
25
134
 
26
- await session.post('Profiler.enable');
135
+ await postAsync("Profiler.enable");
27
136
 
28
137
  if (options?.interval !== undefined) {
29
- await session.post('Profiler.setSamplingInterval', {
138
+ await postAsync("Profiler.setSamplingInterval", {
30
139
  interval: options.interval,
31
140
  });
32
141
  }
33
142
 
34
- await session.post('Profiler.start');
143
+ await postAsync("Profiler.start");
35
144
  profiling = true;
145
+ startHrtime = process.hrtime();
146
+
147
+ if (options?.trackAsync) {
148
+ asyncTracker = new AsyncTracker(resolver, asyncStore);
149
+ asyncTracker.enable();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Stop the profiler, process collected samples, and return a PkgProfile
155
+ * containing the aggregated data. Resets the store afterward.
156
+ *
157
+ * @returns A PkgProfile with the profiling results, or a PkgProfile with empty data if no samples were collected.
158
+ */
159
+ export async function stop(): Promise<PkgProfile> {
160
+ return stopSync();
36
161
  }
37
162
 
38
163
  /**
@@ -40,44 +165,73 @@ export async function track(options?: { interval?: number }): Promise<void> {
40
165
  */
41
166
  export async function clear(): Promise<void> {
42
167
  if (profiling && session) {
43
- await session.post('Profiler.stop');
44
- await session.post('Profiler.disable');
168
+ postSync("Profiler.stop");
169
+ postSync("Profiler.disable");
45
170
  profiling = false;
46
171
  }
172
+ startHrtime = null;
47
173
  store.clear();
174
+ if (asyncTracker) {
175
+ asyncTracker.disable();
176
+ asyncTracker = null;
177
+ }
178
+ asyncStore.clear();
48
179
  }
49
180
 
50
181
  /**
51
- * Stop the profiler, process collected samples through the data pipeline
52
- * (parseFrame -> PackageResolver -> SampleStore), generate an HTML report,
53
- * and return the file path. Resets the store after reporting (clean slate
54
- * for next cycle).
182
+ * High-level convenience for common profiling patterns.
55
183
  *
56
- * Returns the absolute path to the generated HTML file, or empty string
57
- * if no samples were collected.
184
+ * Overload 1: Profile a block of code runs `fn`, stops the profiler, returns PkgProfile.
185
+ * Overload 2: Long-running mode — starts profiler, registers exit handlers, calls `onExit` on shutdown.
58
186
  */
59
- export async function report(): Promise<string> {
60
- if (!profiling || !session) {
61
- console.log('no samples collected');
62
- return '';
187
+ export async function profile(
188
+ fn: () => void | Promise<void>,
189
+ ): Promise<PkgProfile>;
190
+ export async function profile(options: ProfileCallbackOptions): Promise<void>;
191
+ export async function profile(
192
+ fnOrOptions: (() => void | Promise<void>) | ProfileCallbackOptions,
193
+ ): Promise<PkgProfile | void> {
194
+ if (typeof fnOrOptions === "function") {
195
+ await start();
196
+ try {
197
+ await fnOrOptions();
198
+ } finally {
199
+ return stop();
200
+ }
63
201
  }
64
202
 
65
- const { profile } = await session.post('Profiler.stop');
66
- await session.post('Profiler.disable');
67
- profiling = false;
203
+ // Long-running / onExit mode
204
+ const { onExit, ...startOpts } = fnOrOptions;
205
+ await start(startOpts);
68
206
 
69
- processProfile(profile);
207
+ let handled = false;
70
208
 
71
- let filepath = '';
209
+ const handler = (signal?: NodeJS.Signals) => {
210
+ if (handled) return;
211
+ handled = true;
72
212
 
73
- if (store.packages.size > 0) {
74
- filepath = generateReport(store);
75
- } else {
76
- console.log('no samples collected');
77
- }
213
+ process.removeListener("SIGINT", onSignal);
214
+ process.removeListener("SIGTERM", onSignal);
215
+ process.removeListener("exit", onProcessExit);
78
216
 
79
- store.clear();
80
- return filepath;
217
+ const result = stopSync();
218
+ onExit(result);
219
+
220
+ if (signal) {
221
+ process.kill(process.pid, signal);
222
+ }
223
+ };
224
+
225
+ const onSignal = (signal: NodeJS.Signals) => {
226
+ handler(signal);
227
+ };
228
+ const onProcessExit = () => {
229
+ handler();
230
+ };
231
+
232
+ process.once("SIGINT", onSignal);
233
+ process.once("SIGTERM", onSignal);
234
+ process.once("exit", onProcessExit);
81
235
  }
82
236
 
83
237
  /**
@@ -97,11 +251,16 @@ function processProfile(profile: Profiler.Profile): void {
97
251
  const deltaUs = timeDeltas[i] ?? 0;
98
252
  const parsed = parseFrame(node.callFrame as RawCallFrame);
99
253
 
100
- if (parsed.kind === 'user') {
101
- if (parsed.filePath.startsWith('node:')) {
254
+ if (parsed.kind === "user") {
255
+ if (parsed.filePath.startsWith("node:")) {
102
256
  // Node.js built-in: attribute to "node (built-in)" package
103
257
  const relativePath = parsed.filePath.slice(5);
104
- store.record('node (built-in)', relativePath, parsed.functionId, deltaUs);
258
+ store.record(
259
+ "node (built-in)",
260
+ relativePath,
261
+ parsed.functionId,
262
+ deltaUs,
263
+ );
105
264
  } else {
106
265
  const { packageName, relativePath } = resolver.resolve(parsed.filePath);
107
266
  store.record(packageName, relativePath, parsed.functionId, deltaUs);
package/src/types.ts CHANGED
@@ -27,6 +27,9 @@ export interface ReportEntry {
27
27
  timeUs: number; // accumulated microseconds
28
28
  pct: number; // percentage of total (0-100)
29
29
  sampleCount: number; // number of samples attributed
30
+ asyncTimeUs?: number; // accumulated async wait microseconds
31
+ asyncPct?: number; // percentage of total async time (0-100)
32
+ asyncOpCount?: number; // number of async operations attributed
30
33
  }
31
34
 
32
35
  export interface FunctionEntry extends ReportEntry {}
@@ -48,4 +51,24 @@ export interface ReportData {
48
51
  packages: PackageEntry[];
49
52
  otherCount: number;
50
53
  projectName: string;
54
+ totalAsyncTimeUs?: number;
55
+ wallTimeUs?: number;
56
+ }
57
+
58
+ /**
59
+ * Options for `start()`.
60
+ */
61
+ export interface StartOptions {
62
+ /** Sampling interval in microseconds. Default: 1000 */
63
+ interval?: number;
64
+ /** Enable async I/O wait time tracking via async_hooks. Default: false */
65
+ trackAsync?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Options for `profile()` when used in long-running/server mode with an exit callback.
70
+ */
71
+ export interface ProfileCallbackOptions extends StartOptions {
72
+ /** Called with the PkgProfile when the process receives SIGINT/SIGTERM or beforeExit fires. */
73
+ onExit: (result: import('./pkg-profile.js').PkgProfile) => void;
51
74
  }
package/src/reporter.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * Reporter orchestrator.
3
- *
4
- * Aggregates SampleStore data, renders HTML, writes file to cwd,
5
- * and returns the file path.
6
- */
7
-
8
- import { readFileSync, writeFileSync } from 'node:fs';
9
- import { join } from 'node:path';
10
- import type { SampleStore } from './sample-store.js';
11
- import { aggregate } from './reporter/aggregate.js';
12
- import { renderHtml } from './reporter/html.js';
13
-
14
- function generateFilename(): string {
15
- const now = new Date();
16
- const pad = (n: number) => String(n).padStart(2, '0');
17
- const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
18
- const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
19
- return `where-you-at-${date}-${time}.html`;
20
- }
21
-
22
- function readProjectName(cwd: string): string {
23
- try {
24
- const raw = readFileSync(join(cwd, 'package.json'), 'utf-8');
25
- const pkg = JSON.parse(raw) as { name?: string };
26
- return pkg.name ?? 'app';
27
- } catch {
28
- return 'app';
29
- }
30
- }
31
-
32
- export function generateReport(store: SampleStore, cwd?: string): string {
33
- const resolvedCwd = cwd ?? process.cwd();
34
- const projectName = readProjectName(resolvedCwd);
35
- const data = aggregate(store, projectName);
36
- const html = renderHtml(data);
37
- const filename = generateFilename();
38
- const filepath = join(resolvedCwd, filename);
39
- writeFileSync(filepath, html, 'utf-8');
40
- console.log(`Report written to ./${filename}`);
41
- return filepath;
42
- }