@vltpkg/output 1.6.0 → 1.6.2

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 CHANGED
@@ -3,3 +3,34 @@
3
3
  # @vltpkg/output
4
4
 
5
5
  ## TODO
6
+
7
+ ## Tracing
8
+
9
+ `@vltpkg/output/trace` is a low overhead tracer for the install
10
+ pipeline. It is off unless one of these is set, and when off every
11
+ call returns after one boolean check.
12
+
13
+ - `VLT_TRACE=<file>` appends one JSON line per event to `<file>`.
14
+ Every thread writes to the same file with the same wall clock, so
15
+ worker events and main thread events sort onto one timeline by `t`.
16
+ - `VLT_PHASES=1` prints one `PHASES ...` line to stderr at exit: the
17
+ milliseconds between consecutive phase marks, the event loop delay
18
+ percentiles (how long the main thread was busy with synchronous work
19
+ while IO completions waited), and peak memory.
20
+
21
+ ```sh
22
+ VLT_TRACE=trace.jsonl VLT_PHASES=1 vlt install
23
+ ./scripts/trace-report.ts trace.jsonl --bucket=250
24
+ ```
25
+
26
+ The report prints the phase timeline, what each span kind cost
27
+ (`manifest`, `request`, `extract`, `tarball:fetch`, `tarball:unpack`,
28
+ `inflate`, `write`), how many of each were in flight per time bucket,
29
+ and for every phase how long each lane sat idle. An idle lane next to
30
+ a busy one is a head-of-line stall.
31
+
32
+ Event kinds: `phase` (from `mark()`), `<kind>:start`/`<kind>:end`
33
+ (from `span()`, paired by `url`, `spec` or `name`), `request` (the
34
+ registry client's request events), `lane` (scheduler occupancy), `rss`
35
+ (memory samples), `build:level*` (one BFS level of the ideal graph
36
+ build), `open` and `exit`.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import EventEmitter from 'node:events';
2
+ import { tracer } from "./trace.js";
2
3
  export class OutputEmitter {
3
4
  emitter = new EventEmitter();
4
5
  emit(eventName, payload) {
@@ -16,8 +17,15 @@ export class OutputEmitter {
16
17
  export const emitter = new OutputEmitter();
17
18
  export const logRequest = (url, state, details = {}) => {
18
19
  emitter.emit('request', { url, state, ...details });
20
+ if (tracer.file) {
21
+ tracer.trace('request', { ...details, url: String(url), state });
22
+ }
19
23
  };
20
24
  export const graphStep = (step) => {
21
25
  emitter.emit('graphStep', { step, state: 'start' });
22
- return () => emitter.emit('graphStep', { step, state: 'stop' });
26
+ tracer.mark(step);
27
+ return () => {
28
+ emitter.emit('graphStep', { step, state: 'stop' });
29
+ tracer.mark(`${step}:done`);
30
+ };
23
31
  };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Extra fields recorded with a trace event. Keep them flat and small:
3
+ * every event is one JSON line.
4
+ */
5
+ export type TraceFields = Record<string, string | number | boolean | undefined>;
6
+ /**
7
+ * One trace event. `t` is an absolute wall clock in milliseconds
8
+ * (`performance.timeOrigin + performance.now()`), the same clock on every
9
+ * thread, so events from workers and the main thread can be laid on one
10
+ * timeline. `tid` is the worker thread id, 0 for the main thread.
11
+ */
12
+ export type TraceEvent = {
13
+ t: number;
14
+ tid: number;
15
+ ev: string;
16
+ } & TraceFields;
17
+ export type TracerOptions = {
18
+ /** Path of the JSON-lines file to append events to (`VLT_TRACE`). */
19
+ file?: string;
20
+ /** Print a one-line phase summary to stderr at exit (`VLT_PHASES`). */
21
+ phases?: boolean;
22
+ /** The thread id to stamp events with. Defaults to the real one. */
23
+ threadId?: number;
24
+ /** Absolute clock in ms. Defaults to the shared performance clock. */
25
+ now?: () => number;
26
+ /** Appends a chunk of lines to the trace file. */
27
+ append?: (file: string, data: string) => void;
28
+ /** Writes the phase summary line. Defaults to stderr. */
29
+ write?: (line: string) => void;
30
+ /** Flush after this many buffered lines. */
31
+ flushLines?: number;
32
+ /** Flush this many ms after the first buffered line. */
33
+ flushMs?: number;
34
+ /** Sample `rss` this often, in ms. 0 disables sampling. */
35
+ sampleMs?: number;
36
+ /** Whether this is the main thread, which owns the event loop monitor. */
37
+ main?: boolean;
38
+ };
39
+ /**
40
+ * A low overhead tracer for the install pipeline.
41
+ *
42
+ * It is off unless `VLT_TRACE=<file>` or `VLT_PHASES=1` is set, and when
43
+ * off every method returns after one boolean check, so call sites can
44
+ * stay in the hot path.
45
+ *
46
+ * With `VLT_TRACE` every event is appended to the file as one JSON line.
47
+ * Lines are buffered and flushed every `flushLines` lines or `flushMs`
48
+ * ms, and at exit. Worker threads
49
+ * get their own instance from the same env and append to the same file,
50
+ * so the file interleaves threads; sort by `t` to read it.
51
+ *
52
+ * With `VLT_PHASES` the main thread prints one line at exit: the ms
53
+ * between consecutive {@link Tracer.mark} calls, the event loop delay
54
+ * percentiles (the direct measure of how long the main thread was busy
55
+ * with synchronous work while IO completions waited), and peak memory.
56
+ */
57
+ export declare class Tracer {
58
+ #private;
59
+ readonly enabled: boolean;
60
+ readonly file?: string;
61
+ readonly phases: boolean;
62
+ constructor(options?: TracerOptions);
63
+ /**
64
+ * Build a tracer from the environment: `VLT_TRACE` names the file,
65
+ * `VLT_PHASES` (any value but `0` or empty) asks for the summary.
66
+ */
67
+ static fromEnv(env?: Record<string, string | undefined>, options?: Omit<TracerOptions, 'file' | 'phases'>): Tracer;
68
+ /** Record one event. */
69
+ trace(ev: string, fields?: TraceFields): void;
70
+ /**
71
+ * Record a `<ev>:start` event now and return a function that records
72
+ * `<ev>:end` with the elapsed `ms` (plus any fields it is given). The
73
+ * same `fields` are stamped on both ends so the two can be paired.
74
+ */
75
+ span(ev: string, fields?: TraceFields): (end?: TraceFields) => void;
76
+ /**
77
+ * Mark the start of a phase. The summary reports the ms between each
78
+ * mark and the next one, and the trace records a `phase` event.
79
+ */
80
+ mark(name: string, fields?: TraceFields): void;
81
+ /**
82
+ * Record how busy a resource lane is: how many jobs it is running and
83
+ * how many are waiting for a slot. The report derives idle time per
84
+ * lane from these.
85
+ */
86
+ lane(name: string, busy: number, queued: number): void;
87
+ /** Record a memory sample. */
88
+ sample(): void;
89
+ /** Write buffered lines to the file. */
90
+ flush(): void;
91
+ /**
92
+ * The one-line phase summary, or undefined when phases are off. Each
93
+ * phase is reported as the ms from its mark to the next mark (or to
94
+ * now, for the last one).
95
+ */
96
+ summary(): string | undefined;
97
+ /**
98
+ * Stop sampling, flush, and print the summary. Runs at process exit
99
+ * on the main thread; safe to call more than once.
100
+ */
101
+ close(): void;
102
+ }
103
+ /**
104
+ * The process-wide tracer, built from the environment. Worker threads
105
+ * inherit the environment, so they trace to the same file.
106
+ */
107
+ export declare const tracer: Tracer;
108
+ /** Record one event on the process-wide tracer. */
109
+ export declare const trace: (ev: string, fields?: TraceFields) => void;
110
+ /** Open a span on the process-wide tracer. See {@link Tracer.span}. */
111
+ export declare const span: (ev: string, fields?: TraceFields) => ((end?: TraceFields) => void);
112
+ /** Mark a phase on the process-wide tracer. See {@link Tracer.mark}. */
113
+ export declare const mark: (name: string, fields?: TraceFields) => void;
114
+ /** Record lane occupancy on the process-wide tracer. */
115
+ export declare const lane: (name: string, busy: number, queued: number) => void;
package/dist/trace.js ADDED
@@ -0,0 +1,233 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
3
+ import { threadId as ownThreadId } from 'node:worker_threads';
4
+ const sharedNow = () => performance.timeOrigin + performance.now();
5
+ // Owner-only: traces record full request and tarball URLs, which can
6
+ // carry tokens or signed query parameters. The mode applies when the file
7
+ // is created; an existing file keeps its own.
8
+ const defaultAppend = (file, data) => appendFileSync(file, data, { mode: 0o600 });
9
+ const defaultWrite = (line) => process.stderr.write(line + '\n');
10
+ /**
11
+ * A low overhead tracer for the install pipeline.
12
+ *
13
+ * It is off unless `VLT_TRACE=<file>` or `VLT_PHASES=1` is set, and when
14
+ * off every method returns after one boolean check, so call sites can
15
+ * stay in the hot path.
16
+ *
17
+ * With `VLT_TRACE` every event is appended to the file as one JSON line.
18
+ * Lines are buffered and flushed every `flushLines` lines or `flushMs`
19
+ * ms, and at exit. Worker threads
20
+ * get their own instance from the same env and append to the same file,
21
+ * so the file interleaves threads; sort by `t` to read it.
22
+ *
23
+ * With `VLT_PHASES` the main thread prints one line at exit: the ms
24
+ * between consecutive {@link Tracer.mark} calls, the event loop delay
25
+ * percentiles (the direct measure of how long the main thread was busy
26
+ * with synchronous work while IO completions waited), and peak memory.
27
+ */
28
+ export class Tracer {
29
+ enabled;
30
+ file;
31
+ phases;
32
+ #tid;
33
+ #now;
34
+ #append;
35
+ #write;
36
+ #flushLines;
37
+ #flushMs;
38
+ #buffer = [];
39
+ #timer;
40
+ #sampler;
41
+ #marks = [];
42
+ #start;
43
+ #loop;
44
+ #closed = false;
45
+ constructor(options = {}) {
46
+ this.file = options.file || undefined;
47
+ this.phases = !!options.phases;
48
+ this.enabled = !!this.file || this.phases;
49
+ this.#tid = options.threadId ?? ownThreadId;
50
+ this.#now = options.now ?? sharedNow;
51
+ this.#append = options.append ?? defaultAppend;
52
+ this.#write = options.write ?? defaultWrite;
53
+ this.#flushLines = options.flushLines ?? 200;
54
+ this.#flushMs = options.flushMs ?? 50;
55
+ this.#start = this.#now();
56
+ if (!this.enabled)
57
+ return;
58
+ const main = options.main ?? this.#tid === 0;
59
+ if (main) {
60
+ this.#loop = monitorEventLoopDelay({ resolution: 20 });
61
+ this.#loop.enable();
62
+ const sampleMs = options.sampleMs ?? 20;
63
+ if (this.file && sampleMs > 0) {
64
+ this.#sampler = setInterval(() => this.sample(), sampleMs);
65
+ this.#sampler.unref();
66
+ }
67
+ process.once('exit', () => this.close());
68
+ }
69
+ this.trace('open', { pid: process.pid });
70
+ }
71
+ /**
72
+ * Build a tracer from the environment: `VLT_TRACE` names the file,
73
+ * `VLT_PHASES` (any value but `0` or empty) asks for the summary.
74
+ */
75
+ static fromEnv(env = process.env, options = {}) {
76
+ const phases = env.VLT_PHASES;
77
+ return new Tracer({
78
+ ...options,
79
+ file: env.VLT_TRACE,
80
+ phases: !!phases && phases !== '0',
81
+ });
82
+ }
83
+ /** Record one event. */
84
+ trace(ev, fields) {
85
+ if (!this.file || this.#closed)
86
+ return;
87
+ const event = { t: this.#now(), tid: this.#tid, ev };
88
+ if (fields) {
89
+ for (const [k, v] of Object.entries(fields)) {
90
+ if (v !== undefined)
91
+ event[k] = v;
92
+ }
93
+ }
94
+ this.#buffer.push(JSON.stringify(event));
95
+ if (this.#buffer.length >= this.#flushLines)
96
+ this.flush();
97
+ else if (!this.#timer) {
98
+ this.#timer = setTimeout(() => this.flush(), this.#flushMs);
99
+ this.#timer.unref();
100
+ }
101
+ }
102
+ /**
103
+ * Record a `<ev>:start` event now and return a function that records
104
+ * `<ev>:end` with the elapsed `ms` (plus any fields it is given). The
105
+ * same `fields` are stamped on both ends so the two can be paired.
106
+ */
107
+ span(ev, fields) {
108
+ if (!this.file)
109
+ return noop;
110
+ const t0 = this.#now();
111
+ this.trace(`${ev}:start`, fields);
112
+ return end => this.trace(`${ev}:end`, {
113
+ ...fields,
114
+ ...end,
115
+ ms: Math.round((this.#now() - t0) * 1000) / 1000,
116
+ });
117
+ }
118
+ /**
119
+ * Mark the start of a phase. The summary reports the ms between each
120
+ * mark and the next one, and the trace records a `phase` event.
121
+ */
122
+ mark(name, fields) {
123
+ if (!this.enabled)
124
+ return;
125
+ this.#marks.push({ name, t: this.#now() });
126
+ this.trace('phase', { ...fields, name });
127
+ }
128
+ /**
129
+ * Record how busy a resource lane is: how many jobs it is running and
130
+ * how many are waiting for a slot. The report derives idle time per
131
+ * lane from these.
132
+ */
133
+ lane(name, busy, queued) {
134
+ if (!this.file)
135
+ return;
136
+ this.trace('lane', { name, busy, queued });
137
+ }
138
+ /** Record a memory sample. */
139
+ sample() {
140
+ if (!this.file)
141
+ return;
142
+ const m = process.memoryUsage();
143
+ this.trace('rss', {
144
+ rss: m.rss,
145
+ heap: m.heapUsed,
146
+ ext: m.external,
147
+ ab: m.arrayBuffers,
148
+ });
149
+ }
150
+ /** Write buffered lines to the file. */
151
+ flush() {
152
+ if (this.#timer) {
153
+ clearTimeout(this.#timer);
154
+ this.#timer = undefined;
155
+ }
156
+ if (!this.file || !this.#buffer.length)
157
+ return;
158
+ const data = this.#buffer.join('\n') + '\n';
159
+ this.#buffer = [];
160
+ try {
161
+ this.#append(this.file, data);
162
+ }
163
+ catch {
164
+ // a trace that cannot be written must never fail the install
165
+ }
166
+ }
167
+ /**
168
+ * The one-line phase summary, or undefined when phases are off. Each
169
+ * phase is reported as the ms from its mark to the next mark (or to
170
+ * now, for the last one).
171
+ */
172
+ summary() {
173
+ if (!this.phases)
174
+ return undefined;
175
+ const now = this.#now();
176
+ const parts = [];
177
+ for (const [i, m] of this.#marks.entries()) {
178
+ const next = this.#marks[i + 1]?.t ?? now;
179
+ parts.push(`${m.name}=${Math.round(next - m.t)}`);
180
+ }
181
+ parts.push(`total=${Math.round(now - this.#start)}`);
182
+ if (this.#loop) {
183
+ // the histogram is in nanoseconds
184
+ const ms = (n) => Math.round(n / 1e6);
185
+ parts.push(`loop p50=${ms(this.#loop.percentile(50))}` +
186
+ ` p99=${ms(this.#loop.percentile(99))}` +
187
+ ` max=${ms(this.#loop.max)}`);
188
+ }
189
+ const { maxRSS } = process.resourceUsage();
190
+ parts.push(`maxrss=${Math.round(maxRSS / 1024)}MB`);
191
+ return `PHASES ${parts.join(' ')}`;
192
+ }
193
+ /**
194
+ * Stop sampling, flush, and print the summary. Runs at process exit
195
+ * on the main thread; safe to call more than once.
196
+ */
197
+ close() {
198
+ if (this.#closed)
199
+ return;
200
+ if (this.#sampler)
201
+ clearInterval(this.#sampler);
202
+ if (this.enabled) {
203
+ if (this.#loop) {
204
+ this.#loop.disable();
205
+ this.trace('exit', {
206
+ loopP50: Math.round(this.#loop.percentile(50) / 1e6),
207
+ loopP99: Math.round(this.#loop.percentile(99) / 1e6),
208
+ loopMax: Math.round(this.#loop.max / 1e6),
209
+ maxrss: process.resourceUsage().maxRSS,
210
+ });
211
+ }
212
+ const line = this.summary();
213
+ this.flush();
214
+ if (line)
215
+ this.#write(line);
216
+ }
217
+ this.#closed = true;
218
+ }
219
+ }
220
+ const noop = () => { };
221
+ /**
222
+ * The process-wide tracer, built from the environment. Worker threads
223
+ * inherit the environment, so they trace to the same file.
224
+ */
225
+ export const tracer = Tracer.fromEnv();
226
+ /** Record one event on the process-wide tracer. */
227
+ export const trace = (ev, fields) => tracer.trace(ev, fields);
228
+ /** Open a span on the process-wide tracer. See {@link Tracer.span}. */
229
+ export const span = (ev, fields) => tracer.span(ev, fields);
230
+ /** Mark a phase on the process-wide tracer. See {@link Tracer.mark}. */
231
+ export const mark = (name, fields) => tracer.mark(name, fields);
232
+ /** Record lane occupancy on the process-wide tracer. */
233
+ export const lane = (name, busy, queued) => tracer.lane(name, busy, queued);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/output",
3
3
  "description": "Generate output from the vlt CLI",
4
- "version": "1.6.0",
4
+ "version": "1.6.2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,7 +13,7 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/types": "1.6.0"
16
+ "@vltpkg/types": "1.6.2"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@eslint/js": "^9.39.1",
@@ -57,6 +57,11 @@
57
57
  "import": {
58
58
  "default": "./dist/error.js"
59
59
  }
60
+ },
61
+ "./trace": {
62
+ "import": {
63
+ "default": "./dist/trace.js"
64
+ }
60
65
  }
61
66
  },
62
67
  "files": [