@mtharrison/pkg-profiler 1.0.1 → 2.0.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,121 @@
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;
12
20
  const store = new SampleStore();
21
+ const asyncStore = new SampleStore();
13
22
  const resolver = new PackageResolver(process.cwd());
23
+ let asyncTracker: AsyncTracker | null = null;
24
+
25
+ /**
26
+ * Promisify session.post for the normal async API path.
27
+ */
28
+ function postAsync(method: string, params?: object): Promise<any> {
29
+ return new Promise<any>((resolve, reject) => {
30
+ const cb: any = (err: Error | null, result?: any) => {
31
+ if (err) reject(err);
32
+ else resolve(result);
33
+ };
34
+ if (params !== undefined) {
35
+ session!.post(method, params, cb);
36
+ } else {
37
+ session!.post(method, cb);
38
+ }
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Synchronous session.post — works because the V8 inspector executes
44
+ * callbacks synchronously for in-process sessions.
45
+ */
46
+ function postSync(method: string): any {
47
+ let result: any;
48
+ let error: Error | null = null;
49
+ const cb: any = (err: Error | null, params?: any) => {
50
+ error = err;
51
+ result = params;
52
+ };
53
+ session!.post(method, cb);
54
+ if (error) throw error;
55
+ return result;
56
+ }
57
+
58
+ function readProjectName(cwd: string): string {
59
+ try {
60
+ const raw = readFileSync(join(cwd, "package.json"), "utf-8");
61
+ const pkg = JSON.parse(raw) as { name?: string };
62
+ return pkg.name ?? "app";
63
+ } catch {
64
+ return "app";
65
+ }
66
+ }
67
+
68
+ function buildEmptyProfile(): PkgProfile {
69
+ const projectName = readProjectName(process.cwd());
70
+ return new PkgProfile({
71
+ timestamp: new Date().toLocaleString(),
72
+ totalTimeUs: 0,
73
+ packages: [],
74
+ otherCount: 0,
75
+ projectName,
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Shared logic for stopping the profiler and building a PkgProfile.
81
+ * Synchronous — safe to call from process `exit` handlers.
82
+ */
83
+ function stopSync(): PkgProfile {
84
+ if (!profiling || !session) {
85
+ return buildEmptyProfile();
86
+ }
87
+
88
+ const { profile } = postSync("Profiler.stop") as Profiler.StopReturnType;
89
+ postSync("Profiler.disable");
90
+ profiling = false;
91
+
92
+ if (asyncTracker) {
93
+ asyncTracker.disable();
94
+ asyncTracker = null;
95
+ }
96
+
97
+ processProfile(profile);
98
+
99
+ const projectName = readProjectName(process.cwd());
100
+ const data = aggregate(
101
+ store,
102
+ projectName,
103
+ asyncStore.packages.size > 0 ? asyncStore : undefined,
104
+ );
105
+ store.clear();
106
+ asyncStore.clear();
107
+
108
+ return new PkgProfile(data);
109
+ }
14
110
 
15
111
  /**
16
112
  * Start the V8 CPU profiler. If already profiling, this is a safe no-op.
113
+ *
114
+ * @param options - Optional configuration.
115
+ * @param options.interval - Sampling interval in microseconds passed to V8 (defaults to 1000µs). Lower values = higher fidelity but more overhead.
116
+ * @returns Resolves when the profiler is successfully started
17
117
  */
18
- export async function track(options?: { interval?: number }): Promise<void> {
118
+ export async function start(options?: StartOptions): Promise<void> {
19
119
  if (profiling) return;
20
120
 
21
121
  if (session === null) {
@@ -23,16 +123,31 @@ export async function track(options?: { interval?: number }): Promise<void> {
23
123
  session.connect();
24
124
  }
25
125
 
26
- await session.post('Profiler.enable');
126
+ await postAsync("Profiler.enable");
27
127
 
28
128
  if (options?.interval !== undefined) {
29
- await session.post('Profiler.setSamplingInterval', {
129
+ await postAsync("Profiler.setSamplingInterval", {
30
130
  interval: options.interval,
31
131
  });
32
132
  }
33
133
 
34
- await session.post('Profiler.start');
134
+ await postAsync("Profiler.start");
35
135
  profiling = true;
136
+
137
+ if (options?.trackAsync) {
138
+ asyncTracker = new AsyncTracker(resolver, asyncStore);
139
+ asyncTracker.enable();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Stop the profiler, process collected samples, and return a PkgProfile
145
+ * containing the aggregated data. Resets the store afterward.
146
+ *
147
+ * @returns A PkgProfile with the profiling results, or a PkgProfile with empty data if no samples were collected.
148
+ */
149
+ export async function stop(): Promise<PkgProfile> {
150
+ return stopSync();
36
151
  }
37
152
 
38
153
  /**
@@ -40,44 +155,72 @@ export async function track(options?: { interval?: number }): Promise<void> {
40
155
  */
41
156
  export async function clear(): Promise<void> {
42
157
  if (profiling && session) {
43
- await session.post('Profiler.stop');
44
- await session.post('Profiler.disable');
158
+ postSync("Profiler.stop");
159
+ postSync("Profiler.disable");
45
160
  profiling = false;
46
161
  }
47
162
  store.clear();
163
+ if (asyncTracker) {
164
+ asyncTracker.disable();
165
+ asyncTracker = null;
166
+ }
167
+ asyncStore.clear();
48
168
  }
49
169
 
50
170
  /**
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).
171
+ * High-level convenience for common profiling patterns.
55
172
  *
56
- * Returns the absolute path to the generated HTML file, or empty string
57
- * if no samples were collected.
173
+ * Overload 1: Profile a block of code runs `fn`, stops the profiler, returns PkgProfile.
174
+ * Overload 2: Long-running mode — starts profiler, registers exit handlers, calls `onExit` on shutdown.
58
175
  */
59
- export async function report(): Promise<string> {
60
- if (!profiling || !session) {
61
- console.log('no samples collected');
62
- return '';
176
+ export async function profile(
177
+ fn: () => void | Promise<void>,
178
+ ): Promise<PkgProfile>;
179
+ export async function profile(options: ProfileCallbackOptions): Promise<void>;
180
+ export async function profile(
181
+ fnOrOptions: (() => void | Promise<void>) | ProfileCallbackOptions,
182
+ ): Promise<PkgProfile | void> {
183
+ if (typeof fnOrOptions === "function") {
184
+ await start();
185
+ try {
186
+ await fnOrOptions();
187
+ } finally {
188
+ return stop();
189
+ }
63
190
  }
64
191
 
65
- const { profile } = await session.post('Profiler.stop');
66
- await session.post('Profiler.disable');
67
- profiling = false;
192
+ // Long-running / onExit mode
193
+ const { onExit, ...startOpts } = fnOrOptions;
194
+ await start(startOpts);
68
195
 
69
- processProfile(profile);
196
+ let handled = false;
70
197
 
71
- let filepath = '';
198
+ const handler = (signal?: NodeJS.Signals) => {
199
+ if (handled) return;
200
+ handled = true;
72
201
 
73
- if (store.packages.size > 0) {
74
- filepath = generateReport(store);
75
- } else {
76
- console.log('no samples collected');
77
- }
202
+ process.removeListener("SIGINT", onSignal);
203
+ process.removeListener("SIGTERM", onSignal);
204
+ process.removeListener("exit", onProcessExit);
78
205
 
79
- store.clear();
80
- return filepath;
206
+ const result = stopSync();
207
+ onExit(result);
208
+
209
+ if (signal) {
210
+ process.kill(process.pid, signal);
211
+ }
212
+ };
213
+
214
+ const onSignal = (signal: NodeJS.Signals) => {
215
+ handler(signal);
216
+ };
217
+ const onProcessExit = () => {
218
+ handler();
219
+ };
220
+
221
+ process.once("SIGINT", onSignal);
222
+ process.once("SIGTERM", onSignal);
223
+ process.once("exit", onProcessExit);
81
224
  }
82
225
 
83
226
  /**
@@ -97,11 +240,16 @@ function processProfile(profile: Profiler.Profile): void {
97
240
  const deltaUs = timeDeltas[i] ?? 0;
98
241
  const parsed = parseFrame(node.callFrame as RawCallFrame);
99
242
 
100
- if (parsed.kind === 'user') {
101
- if (parsed.filePath.startsWith('node:')) {
243
+ if (parsed.kind === "user") {
244
+ if (parsed.filePath.startsWith("node:")) {
102
245
  // Node.js built-in: attribute to "node (built-in)" package
103
246
  const relativePath = parsed.filePath.slice(5);
104
- store.record('node (built-in)', relativePath, parsed.functionId, deltaUs);
247
+ store.record(
248
+ "node (built-in)",
249
+ relativePath,
250
+ parsed.functionId,
251
+ deltaUs,
252
+ );
105
253
  } else {
106
254
  const { packageName, relativePath } = resolver.resolve(parsed.filePath);
107
255
  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 {}
@@ -47,4 +50,24 @@ export interface ReportData {
47
50
  totalTimeUs: number;
48
51
  packages: PackageEntry[];
49
52
  otherCount: number;
53
+ projectName: string;
54
+ totalAsyncTimeUs?: number;
55
+ }
56
+
57
+ /**
58
+ * Options for `start()`.
59
+ */
60
+ export interface StartOptions {
61
+ /** Sampling interval in microseconds. Default: 1000 */
62
+ interval?: number;
63
+ /** Enable async I/O wait time tracking via async_hooks. Default: false */
64
+ trackAsync?: boolean;
65
+ }
66
+
67
+ /**
68
+ * Options for `profile()` when used in long-running/server mode with an exit callback.
69
+ */
70
+ export interface ProfileCallbackOptions extends StartOptions {
71
+ /** Called with the PkgProfile when the process receives SIGINT/SIGTERM or beforeExit fires. */
72
+ onExit: (result: import('./pkg-profile.js').PkgProfile) => void;
50
73
  }
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
- }