@mtharrison/pkg-profiler 1.1.0 → 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/README.md +62 -7
- package/dist/index.cjs +774 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -11
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +91 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +769 -151
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/async-tracker.ts +178 -0
- package/src/frame-parser.ts +3 -0
- package/src/index.ts +10 -3
- package/src/pkg-profile.ts +73 -0
- package/src/reporter/aggregate.ts +149 -64
- package/src/reporter/format.ts +10 -0
- package/src/reporter/html.ts +330 -9
- package/src/sampler.ts +186 -38
- package/src/types.ts +22 -0
- package/src/reporter.ts +0 -42
package/src/sampler.ts
CHANGED
|
@@ -1,21 +1,121 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Profiler } from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
|
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
|
|
126
|
+
await postAsync("Profiler.enable");
|
|
27
127
|
|
|
28
128
|
if (options?.interval !== undefined) {
|
|
29
|
-
await
|
|
129
|
+
await postAsync("Profiler.setSamplingInterval", {
|
|
30
130
|
interval: options.interval,
|
|
31
131
|
});
|
|
32
132
|
}
|
|
33
133
|
|
|
34
|
-
await
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
192
|
+
// Long-running / onExit mode
|
|
193
|
+
const { onExit, ...startOpts } = fnOrOptions;
|
|
194
|
+
await start(startOpts);
|
|
68
195
|
|
|
69
|
-
|
|
196
|
+
let handled = false;
|
|
70
197
|
|
|
71
|
-
|
|
198
|
+
const handler = (signal?: NodeJS.Signals) => {
|
|
199
|
+
if (handled) return;
|
|
200
|
+
handled = true;
|
|
72
201
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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 ===
|
|
101
|
-
if (parsed.filePath.startsWith(
|
|
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(
|
|
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 {}
|
|
@@ -48,4 +51,23 @@ export interface ReportData {
|
|
|
48
51
|
packages: PackageEntry[];
|
|
49
52
|
otherCount: number;
|
|
50
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;
|
|
51
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
|
-
}
|