@percy/logger 1.31.14-beta.0 → 1.31.14-beta.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/dist/index.js +9 -0
- package/dist/logger.js +427 -58
- package/package.json +2 -2
- package/test/helpers.js +16 -3
package/dist/index.js
CHANGED
|
@@ -19,6 +19,15 @@ Object.defineProperties(logger, {
|
|
|
19
19
|
query: {
|
|
20
20
|
value: (...args) => logger.instance.query(...args)
|
|
21
21
|
},
|
|
22
|
+
snapshotLogs: {
|
|
23
|
+
value: (...args) => logger.instance.snapshotLogs(...args)
|
|
24
|
+
},
|
|
25
|
+
evictSnapshot: {
|
|
26
|
+
value: (...args) => logger.instance.evictSnapshot(...args)
|
|
27
|
+
},
|
|
28
|
+
reset: {
|
|
29
|
+
value: (...args) => logger.instance.reset(...args)
|
|
30
|
+
},
|
|
22
31
|
format: {
|
|
23
32
|
value: (...args) => logger.instance.format(...args)
|
|
24
33
|
},
|
package/dist/logger.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
1
5
|
import { colors } from './utils.js';
|
|
2
6
|
const LINE_PAD_REGEXP = /^(\n*)(.*?)(\n*)$/s;
|
|
3
7
|
const URL_REGEXP = /https?:\/\/[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:;%_+.~#?&//=[\]]*)/i;
|
|
@@ -7,30 +11,55 @@ const LOG_LEVELS = {
|
|
|
7
11
|
warn: 2,
|
|
8
12
|
error: 3
|
|
9
13
|
};
|
|
14
|
+
const FLUSH_AT_ENTRIES = 500;
|
|
15
|
+
const FLUSH_TIMER_MS = 100;
|
|
16
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
10
17
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
18
|
+
// Hooks latch + active-instance set kept on the `process` object via Symbol.for
|
|
19
|
+
// so that they are shared across module copies (the ESM loader-mock path used
|
|
20
|
+
// by tests creates fresh module instances; a module-scoped variable would let
|
|
21
|
+
// each one register its own listener and accumulate into MaxListenersWarning).
|
|
22
|
+
// Using a Set rather than a single pointer also handles transitional states
|
|
23
|
+
// where two PercyLogger instances are alive at once (e.g. test setups that
|
|
24
|
+
// don't reset between cases) — both files get cleaned at exit.
|
|
25
|
+
const EXIT_HOOKS_INSTALLED = Symbol.for('@percy/logger.exitHooksInstalled');
|
|
26
|
+
const ACTIVE_INSTANCES = Symbol.for('@percy/logger.activeInstances');
|
|
27
|
+
|
|
28
|
+
// A PercyLogger writes logs to stdout/stderr and persists every entry to a
|
|
29
|
+
// JSONL file under os.tmpdir()/percy-logs/<pid>/, keeping resident memory
|
|
30
|
+
// bounded across long builds. Falls back to an unbounded in-memory Set if
|
|
31
|
+
// disk is unavailable (or if the rollback env var PERCY_LOGS_IN_MEMORY is set).
|
|
13
32
|
export class PercyLogger {
|
|
14
|
-
// default log level
|
|
15
33
|
level = 'info';
|
|
16
|
-
|
|
17
|
-
// namespace regular expressions used to determine which debug logs to write
|
|
18
34
|
namespaces = {
|
|
19
35
|
include: [/^.*?$/],
|
|
20
36
|
exclude: [/^ci$/, /^sdk$/]
|
|
21
37
|
};
|
|
22
|
-
|
|
23
|
-
// in-memory store for logs and meta info
|
|
24
|
-
messages = new Set();
|
|
25
|
-
|
|
26
|
-
// track deprecations to limit noisy logging
|
|
27
38
|
deprecations = new Set();
|
|
28
39
|
|
|
29
|
-
//
|
|
40
|
+
// disk-backed store state
|
|
41
|
+
diskMode = 'disk';
|
|
42
|
+
diskPath = null;
|
|
43
|
+
diskSize = 0;
|
|
44
|
+
writeBuffer = [];
|
|
45
|
+
flushTimer = null;
|
|
46
|
+
// snapshotLogs cache: Map<key, entry[]>. Bounded by # of un-evicted snapshot
|
|
47
|
+
// keys at any moment; evictSnapshot() drops them. Entries logged AFTER the
|
|
48
|
+
// eviction repopulate the cache through the next _refreshCache delta. Pre-
|
|
49
|
+
// eviction entries are restored on retry via the pendingFullScan re-scan in
|
|
50
|
+
// snapshotLogs (preserves master's `messages` Set retain-everything semantics
|
|
51
|
+
// for retried snapshots).
|
|
52
|
+
cache = new Map();
|
|
53
|
+
cacheCursor = 0;
|
|
54
|
+
pendingFullScan = new Set();
|
|
55
|
+
fallback = null;
|
|
56
|
+
// Lazy Map<key, entry[]> index over fallback Set. Populated on first
|
|
57
|
+
// snapshotLogs() call and maintained by _record. Avoids O(N²) scans when
|
|
58
|
+
// PERCY_LOGS_IN_MEMORY=1 is the active mode for a long-running build.
|
|
59
|
+
fallbackByKey = null;
|
|
60
|
+
writeFailureWarned = false;
|
|
30
61
|
static stdout = process.stdout;
|
|
31
62
|
static stderr = process.stderr;
|
|
32
|
-
|
|
33
|
-
// Handles setting env var values and returns a singleton
|
|
34
63
|
constructor() {
|
|
35
64
|
let {
|
|
36
65
|
instance = this
|
|
@@ -40,18 +69,27 @@ export class PercyLogger {
|
|
|
40
69
|
} else if (process.env.PERCY_LOGLEVEL) {
|
|
41
70
|
instance.loglevel(process.env.PERCY_LOGLEVEL);
|
|
42
71
|
}
|
|
72
|
+
|
|
73
|
+
// If the rollback / test env var is set, flip to memory mode immediately so
|
|
74
|
+
// log() never goes through the disk buffer at all. Drain any entries that
|
|
75
|
+
// were already queued in disk mode so they aren't stranded after the flip.
|
|
76
|
+
// Note: PERCY_LOGS_IN_MEMORY is only consulted here at construction time;
|
|
77
|
+
// setting or unsetting it later has no effect because subsequent
|
|
78
|
+
// `new Logger()` returns the cached singleton — tests that need to flip
|
|
79
|
+
// mode mid-process must `delete logger.constructor.instance` first.
|
|
80
|
+
if (process.env.PERCY_LOGS_IN_MEMORY === '1' && instance.diskMode === 'disk' && !instance.diskPath) {
|
|
81
|
+
instance.diskMode = 'memory';
|
|
82
|
+
instance.fallback ?? (instance.fallback = new Set());
|
|
83
|
+
/* istanbul ignore if: only triggered when env=1 is set after logs have already buffered */
|
|
84
|
+
if (instance.writeBuffer.length) instance._drainBufferToMemory();
|
|
85
|
+
}
|
|
43
86
|
this.constructor.instance = instance;
|
|
44
87
|
return instance;
|
|
45
88
|
}
|
|
46
|
-
|
|
47
|
-
// Change log level at any time or return the current log level
|
|
48
89
|
loglevel(level) {
|
|
49
90
|
if (level) this.level = level;
|
|
50
91
|
return this.level;
|
|
51
92
|
}
|
|
52
|
-
|
|
53
|
-
// Change namespaces by generating an array of namespace regular expressions from a
|
|
54
|
-
// comma separated debug string
|
|
55
93
|
debug(namespaces) {
|
|
56
94
|
if (this.namespaces.string === namespaces) return;
|
|
57
95
|
this.namespaces.string = namespaces;
|
|
@@ -72,8 +110,6 @@ export class PercyLogger {
|
|
|
72
110
|
exclude: []
|
|
73
111
|
});
|
|
74
112
|
}
|
|
75
|
-
|
|
76
|
-
// Creates a new log group and returns level specific functions for logging
|
|
77
113
|
group(name) {
|
|
78
114
|
return Object.keys(LOG_LEVELS).reduce((group, level) => Object.assign(group, {
|
|
79
115
|
[level]: this.log.bind(this, name, level)
|
|
@@ -88,12 +124,101 @@ export class PercyLogger {
|
|
|
88
124
|
});
|
|
89
125
|
}
|
|
90
126
|
|
|
91
|
-
//
|
|
127
|
+
// Returns matching entries. The semantics differ by mode:
|
|
128
|
+
// - memory mode: returns the live entry refs from the fallback Set; mutations
|
|
129
|
+
// to entry.message (e.g. redactSecrets in percy.js sendBuildLogs) persist
|
|
130
|
+
// in the Set. This mirrors master's `messages` contract.
|
|
131
|
+
// - disk mode: streams a fresh JSONL pass per call and returns freshly-parsed
|
|
132
|
+
// copies. Mutations are local to the caller and never reach disk —
|
|
133
|
+
// intentional, since disk-backed redaction would require a rewrite.
|
|
134
|
+
// Production consumers (sendBuildLogs) only depend on the array returned by
|
|
135
|
+
// redactSecrets, not the mutation side-effect, so both modes are correct.
|
|
92
136
|
query(filter) {
|
|
93
|
-
|
|
137
|
+
if (this.diskMode === 'memory') {
|
|
138
|
+
return Array.from(this.fallback).filter(filter);
|
|
139
|
+
}
|
|
140
|
+
this._flushSync();
|
|
141
|
+
if (this.diskMode === 'memory') {
|
|
142
|
+
return Array.from(this.fallback).filter(filter);
|
|
143
|
+
}
|
|
144
|
+
return this._scanDisk(filter);
|
|
94
145
|
}
|
|
95
146
|
|
|
96
|
-
//
|
|
147
|
+
// Returns entries tagged with the given snapshot meta. In disk mode, reads
|
|
148
|
+
// only the disk delta since the last call to amortize the work in defer
|
|
149
|
+
// mode (snapshots accumulate; logs route through the cache lazily). On
|
|
150
|
+
// retry — when evictSnapshot was called and snapshotLogs is invoked again
|
|
151
|
+
// for the same meta — pre-eviction entries are recovered from a full
|
|
152
|
+
// disk scan so the per-snapshot log resource includes both attempts.
|
|
153
|
+
// Returns a shallow copy so callers can mutate without corrupting the cache.
|
|
154
|
+
snapshotLogs(meta) {
|
|
155
|
+
let key = this._snapshotKey({
|
|
156
|
+
snapshot: meta
|
|
157
|
+
});
|
|
158
|
+
if (!key) return [];
|
|
159
|
+
if (this.diskMode === 'memory') {
|
|
160
|
+
return [...this._filterFallback(key)];
|
|
161
|
+
}
|
|
162
|
+
this._flushSync();
|
|
163
|
+
/* istanbul ignore if: defensive — _flushSync only flips mode via _fallbackToMemory, which our snapshotLogs tests don't exercise mid-call */
|
|
164
|
+
if (this.diskMode === 'memory') {
|
|
165
|
+
return [...this._filterFallback(key)];
|
|
166
|
+
}
|
|
167
|
+
this._refreshCache();
|
|
168
|
+
|
|
169
|
+
// Retry path: this key was previously evicted. The incremental cursor has
|
|
170
|
+
// already advanced past its prior entries, so a delta-only refresh would
|
|
171
|
+
// miss them. Re-scan the entire JSONL once for this key to restore parity
|
|
172
|
+
// with master (where `messages = new Set()` retained every entry).
|
|
173
|
+
if (this.pendingFullScan.has(key)) {
|
|
174
|
+
this.pendingFullScan.delete(key);
|
|
175
|
+
let full = this._scanDisk(e => this._snapshotKey(e === null || e === void 0 ? void 0 : e.meta) === key);
|
|
176
|
+
// If full.length is 0 the key already has no cache entry (evictSnapshot
|
|
177
|
+
// deleted it) — leave it absent so cache.get returns undefined below.
|
|
178
|
+
if (full.length) this.cache.set(key, full);
|
|
179
|
+
}
|
|
180
|
+
let cached = this.cache.get(key);
|
|
181
|
+
return cached ? [...cached] : [];
|
|
182
|
+
}
|
|
183
|
+
evictSnapshot(meta) {
|
|
184
|
+
let key = this._snapshotKey({
|
|
185
|
+
snapshot: meta
|
|
186
|
+
});
|
|
187
|
+
if (!key) return;
|
|
188
|
+
this.cache.delete(key);
|
|
189
|
+
// Mark for full-disk rescan on the next snapshotLogs(meta) — needed so a
|
|
190
|
+
// retry/re-discovery flow recovers the pre-eviction entries (master
|
|
191
|
+
// parity). Cleared once consumed to keep the rescan one-shot per evict.
|
|
192
|
+
this.pendingFullScan.add(key);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Resets all logger state. Cleans up the disk file; next log will lazily reinit.
|
|
196
|
+
reset() {
|
|
197
|
+
var _process$ACTIVE_INSTA;
|
|
198
|
+
// Why: discard buffered entries before _cleanup — between tests, the old
|
|
199
|
+
// diskPath may reference a file from a prior mockfs volume that no longer
|
|
200
|
+
// exists in the real fs. Letting _flushSync run would trip ENOENT and
|
|
201
|
+
// emit the disk-fallback warning into the next test's captured stderr.
|
|
202
|
+
this.writeBuffer = [];
|
|
203
|
+
/* istanbul ignore if: defensive — the only code path that schedules a
|
|
204
|
+
timer also drains via query() before reset() in tests */
|
|
205
|
+
if (this.flushTimer) {
|
|
206
|
+
clearTimeout(this.flushTimer);
|
|
207
|
+
this.flushTimer = null;
|
|
208
|
+
}
|
|
209
|
+
this._cleanup();
|
|
210
|
+
(_process$ACTIVE_INSTA = process[ACTIVE_INSTANCES]) === null || _process$ACTIVE_INSTA === void 0 || _process$ACTIVE_INSTA.delete(this);
|
|
211
|
+
this.diskPath = null;
|
|
212
|
+
this.diskSize = 0;
|
|
213
|
+
this.cache.clear();
|
|
214
|
+
this.cacheCursor = 0;
|
|
215
|
+
this.pendingFullScan.clear();
|
|
216
|
+
this.fallback = null;
|
|
217
|
+
this.fallbackByKey = null;
|
|
218
|
+
this.diskMode = 'disk';
|
|
219
|
+
this.writeFailureWarned = false;
|
|
220
|
+
this.deprecations = new Set();
|
|
221
|
+
}
|
|
97
222
|
format(debug, level, message, elapsed) {
|
|
98
223
|
let color = (n, m) => this.isTTY ? colors[n](m) : m;
|
|
99
224
|
let begin,
|
|
@@ -101,47 +226,30 @@ export class PercyLogger {
|
|
|
101
226
|
suffix = '';
|
|
102
227
|
let label = 'percy';
|
|
103
228
|
if (arguments.length === 1) {
|
|
104
|
-
// format(message)
|
|
105
229
|
[debug, message] = [null, debug];
|
|
106
230
|
} else if (arguments.length === 2) {
|
|
107
|
-
// format(debug, message)
|
|
108
231
|
[level, message] = [null, level];
|
|
109
232
|
}
|
|
110
|
-
|
|
111
|
-
// do not format leading or trailing newlines
|
|
112
233
|
[, begin, message, end] = message.match(LINE_PAD_REGEXP);
|
|
113
|
-
|
|
114
|
-
// include debug information
|
|
115
234
|
if (this.level === 'debug') {
|
|
116
235
|
if (debug) label += `:${debug}`;
|
|
117
|
-
|
|
118
|
-
// include elapsed time since last log
|
|
119
236
|
if (elapsed != null) {
|
|
120
237
|
suffix = ' ' + color('grey', `(${elapsed}ms)`);
|
|
121
238
|
}
|
|
122
239
|
}
|
|
123
|
-
|
|
124
|
-
// add colors
|
|
125
240
|
label = color('magenta', label);
|
|
126
241
|
if (level === 'error') {
|
|
127
|
-
// red errors
|
|
128
242
|
message = color('red', message);
|
|
129
243
|
} else if (level === 'warn') {
|
|
130
|
-
// yellow warnings
|
|
131
244
|
message = color('yellow', message);
|
|
132
245
|
} else if (level === 'info' || level === 'debug') {
|
|
133
|
-
// blue info and debug URLs
|
|
134
246
|
message = message.replace(URL_REGEXP, color('blue', '$&'));
|
|
135
247
|
}
|
|
136
248
|
return `${begin}[${label}] ${message}${suffix}${end}`;
|
|
137
249
|
}
|
|
138
|
-
|
|
139
|
-
// True if stdout is a TTY interface
|
|
140
250
|
get isTTY() {
|
|
141
251
|
return !!this.constructor.stdout.isTTY;
|
|
142
252
|
}
|
|
143
|
-
|
|
144
|
-
// Replaces the current line with a log message
|
|
145
253
|
progress(debug, message, persist) {
|
|
146
254
|
if (!this.shouldLog(debug, 'info')) return;
|
|
147
255
|
let {
|
|
@@ -158,27 +266,17 @@ export class PercyLogger {
|
|
|
158
266
|
persist
|
|
159
267
|
};
|
|
160
268
|
}
|
|
161
|
-
|
|
162
|
-
// Returns true or false if the level and debug group can write messages to stdio
|
|
163
269
|
shouldLog(debug, level) {
|
|
164
270
|
return LOG_LEVELS[level] != null && LOG_LEVELS[level] >= LOG_LEVELS[this.level] && !this.namespaces.exclude.some(ns => ns.test(debug)) && this.namespaces.include.some(ns => ns.test(debug));
|
|
165
271
|
}
|
|
166
|
-
|
|
167
|
-
// Ensures that deprecation messages are not logged more than once
|
|
168
272
|
deprecated(debug, message, meta) {
|
|
169
273
|
if (this.deprecations.has(message)) return;
|
|
170
274
|
this.deprecations.add(message);
|
|
171
275
|
this.log(debug, 'warn', `Warning: ${message}`, meta);
|
|
172
276
|
}
|
|
173
|
-
|
|
174
|
-
// Generic log method accepts a debug group, log level, log message, and optional meta
|
|
175
|
-
// information to store with the message and other info
|
|
176
277
|
log(debug, level, message, meta = {}) {
|
|
177
|
-
// message might be an error-like object
|
|
178
278
|
let err = typeof message !== 'string' && (level === 'debug' || level === 'error');
|
|
179
279
|
err && (err = message.message ? Error.prototype.toString.call(message) : message.toString());
|
|
180
|
-
|
|
181
|
-
// save log entries
|
|
182
280
|
let timestamp = Date.now();
|
|
183
281
|
message = err ? message.stack || err : message.toString();
|
|
184
282
|
let entry = {
|
|
@@ -189,11 +287,8 @@ export class PercyLogger {
|
|
|
189
287
|
timestamp,
|
|
190
288
|
error: !!err
|
|
191
289
|
};
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
// maybe write the message to stdio
|
|
290
|
+
this._record(entry);
|
|
195
291
|
if (this.shouldLog(debug, level)) {
|
|
196
|
-
// unless the loglevel is debug, write shorter error messages
|
|
197
292
|
if (err && this.level !== 'debug') message = err;
|
|
198
293
|
this.write({
|
|
199
294
|
...entry,
|
|
@@ -202,8 +297,6 @@ export class PercyLogger {
|
|
|
202
297
|
this.lastlog = timestamp;
|
|
203
298
|
}
|
|
204
299
|
}
|
|
205
|
-
|
|
206
|
-
// Writes a log entry to stdio based on the loglevel
|
|
207
300
|
write({
|
|
208
301
|
debug,
|
|
209
302
|
level,
|
|
@@ -219,8 +312,6 @@ export class PercyLogger {
|
|
|
219
312
|
stdout,
|
|
220
313
|
stderr
|
|
221
314
|
} = this.constructor;
|
|
222
|
-
|
|
223
|
-
// clear any logged progress
|
|
224
315
|
if (progress) {
|
|
225
316
|
stdout.cursorTo(0);
|
|
226
317
|
stdout.clearLine(0);
|
|
@@ -228,5 +319,283 @@ export class PercyLogger {
|
|
|
228
319
|
(level === 'info' ? stdout : stderr).write(msg + '\n');
|
|
229
320
|
if (!((_this$_progress = this._progress) !== null && _this$_progress !== void 0 && _this$_progress.persist)) delete this._progress;else if (progress) stdout.write(progress.message);
|
|
230
321
|
}
|
|
322
|
+
|
|
323
|
+
// ── internals ───────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
_filterFallback(key) {
|
|
326
|
+
if (!this.fallbackByKey) {
|
|
327
|
+
// Lazy build: O(N) once, then O(1) per call. Keeps PERCY_LOGS_IN_MEMORY=1
|
|
328
|
+
// mode usable for 10k-snapshot builds where snapshotLogs is called per
|
|
329
|
+
// snapshot.
|
|
330
|
+
this.fallbackByKey = new Map();
|
|
331
|
+
for (let entry of this.fallback) {
|
|
332
|
+
let k = this._snapshotKey(entry === null || entry === void 0 ? void 0 : entry.meta);
|
|
333
|
+
if (!k) continue;
|
|
334
|
+
let arr = this.fallbackByKey.get(k);
|
|
335
|
+
if (!arr) this.fallbackByKey.set(k, arr = []);
|
|
336
|
+
arr.push(entry);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return this.fallbackByKey.get(key) || [];
|
|
340
|
+
}
|
|
341
|
+
_record(entry) {
|
|
342
|
+
if (this.diskMode === 'memory') {
|
|
343
|
+
this.fallback.add(entry);
|
|
344
|
+
// Maintain the lazy index incrementally if it exists. If it hasn't been
|
|
345
|
+
// built yet, _filterFallback will scan fallback once on next call.
|
|
346
|
+
if (this.fallbackByKey) {
|
|
347
|
+
var _entry;
|
|
348
|
+
let k = this._snapshotKey((_entry = entry) === null || _entry === void 0 ? void 0 : _entry.meta);
|
|
349
|
+
if (k) {
|
|
350
|
+
let arr = this.fallbackByKey.get(k);
|
|
351
|
+
if (!arr) this.fallbackByKey.set(k, arr = []);
|
|
352
|
+
arr.push(entry);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let line;
|
|
358
|
+
try {
|
|
359
|
+
line = JSON.stringify(entry) + '\n';
|
|
360
|
+
} catch {
|
|
361
|
+
var _entry$meta;
|
|
362
|
+
// Why: circular references in meta would otherwise kill this log call.
|
|
363
|
+
// Preserve meta.snapshot so the entry still routes via snapshotLogs.
|
|
364
|
+
let safeMeta = {
|
|
365
|
+
unserializable: true
|
|
366
|
+
};
|
|
367
|
+
if ((_entry$meta = entry.meta) !== null && _entry$meta !== void 0 && _entry$meta.snapshot) safeMeta.snapshot = entry.meta.snapshot;
|
|
368
|
+
entry = {
|
|
369
|
+
...entry,
|
|
370
|
+
meta: safeMeta
|
|
371
|
+
};
|
|
372
|
+
line = JSON.stringify(entry) + '\n';
|
|
373
|
+
}
|
|
374
|
+
let length = Buffer.byteLength(line, 'utf8');
|
|
375
|
+
this.writeBuffer.push({
|
|
376
|
+
line,
|
|
377
|
+
length
|
|
378
|
+
});
|
|
379
|
+
this._scheduleFlush();
|
|
380
|
+
if (this.writeBuffer.length >= FLUSH_AT_ENTRIES) this._flushSync();
|
|
381
|
+
}
|
|
382
|
+
_snapshotKey(meta) {
|
|
383
|
+
let s = meta === null || meta === void 0 ? void 0 : meta.snapshot;
|
|
384
|
+
if (!s || !s.testCase && !s.name) return null;
|
|
385
|
+
// NUL byte separator — `|` collides on legitimate names like
|
|
386
|
+
// ('a|b','c') vs ('a','b|c'); NUL is forbidden in test/snapshot names.
|
|
387
|
+
return `${s.testCase || ''}\x00${s.name || ''}`;
|
|
388
|
+
}
|
|
389
|
+
_scheduleFlush() {
|
|
390
|
+
var _this$flushTimer$unre, _this$flushTimer;
|
|
391
|
+
if (this.flushTimer) return;
|
|
392
|
+
this.flushTimer = setTimeout(() => {
|
|
393
|
+
this.flushTimer = null;
|
|
394
|
+
this._flushSync();
|
|
395
|
+
}, FLUSH_TIMER_MS);
|
|
396
|
+
(_this$flushTimer$unre = (_this$flushTimer = this.flushTimer).unref) === null || _this$flushTimer$unre === void 0 || _this$flushTimer$unre.call(_this$flushTimer);
|
|
397
|
+
}
|
|
398
|
+
_flushSync() {
|
|
399
|
+
if (this.flushTimer) {
|
|
400
|
+
clearTimeout(this.flushTimer);
|
|
401
|
+
this.flushTimer = null;
|
|
402
|
+
}
|
|
403
|
+
if (!this.writeBuffer.length) return;
|
|
404
|
+
this._ensureDiskInit();
|
|
405
|
+
if (this.diskMode !== 'disk') {
|
|
406
|
+
this._drainBufferToMemory();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let chunk = '';
|
|
410
|
+
let written = 0;
|
|
411
|
+
for (let item of this.writeBuffer) {
|
|
412
|
+
chunk += item.line;
|
|
413
|
+
written += item.length;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
fs.appendFileSync(this.diskPath, chunk);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
this._fallbackToMemory(err);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.diskSize += written;
|
|
422
|
+
this.writeBuffer = [];
|
|
423
|
+
}
|
|
424
|
+
_drainBufferToMemory() {
|
|
425
|
+
/* istanbul ignore next: defensive — fallback is always set before drain runs */
|
|
426
|
+
if (!this.fallback) this.fallback = new Set();
|
|
427
|
+
for (let {
|
|
428
|
+
line
|
|
429
|
+
} of this.writeBuffer) {
|
|
430
|
+
/* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
|
|
431
|
+
try {
|
|
432
|
+
this.fallback.add(JSON.parse(line.replace(/\n$/, '')));
|
|
433
|
+
} catch {/* skip */}
|
|
434
|
+
}
|
|
435
|
+
this.writeBuffer = [];
|
|
436
|
+
}
|
|
437
|
+
_ensureDiskInit() {
|
|
438
|
+
if (this.diskPath || this.diskMode !== 'disk') return;
|
|
439
|
+
try {
|
|
440
|
+
// Per-pid subdir keeps concurrent percy processes (CI matrix, parallel
|
|
441
|
+
// test workers, npx invocations) from clobbering each other's files.
|
|
442
|
+
let dir = join(tmpdir(), 'percy-logs', String(process.pid));
|
|
443
|
+
fs.mkdirSync(dir, {
|
|
444
|
+
recursive: true
|
|
445
|
+
});
|
|
446
|
+
this.diskPath = join(dir, `${Date.now()}-${randomBytes(8).toString('hex')}.jsonl`);
|
|
447
|
+
fs.writeFileSync(this.diskPath, '');
|
|
448
|
+
this._installExitHooks();
|
|
449
|
+
} catch {
|
|
450
|
+
this.diskMode = 'memory';
|
|
451
|
+
/* istanbul ignore next: defensive — fallback may already be set */
|
|
452
|
+
this.fallback ?? (this.fallback = new Set());
|
|
453
|
+
this.diskPath = null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Reads the disk delta into the snapshotLogs cache, grouped by snapshotKey.
|
|
458
|
+
// Streams in 64KB chunks so a long defer-mode build draining hundreds of MB
|
|
459
|
+
// at end-of-build doesn't allocate a single huge buffer.
|
|
460
|
+
_refreshCache() {
|
|
461
|
+
if (this.cacheCursor >= this.diskSize) return;
|
|
462
|
+
let fd = fs.openSync(this.diskPath, 'r');
|
|
463
|
+
try {
|
|
464
|
+
let buf = Buffer.alloc(READ_CHUNK_BYTES);
|
|
465
|
+
let offset = this.cacheCursor;
|
|
466
|
+
let partial = '';
|
|
467
|
+
while (offset < this.diskSize) {
|
|
468
|
+
let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset);
|
|
469
|
+
let n = fs.readSync(fd, buf, 0, toRead, offset);
|
|
470
|
+
offset += n;
|
|
471
|
+
let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n');
|
|
472
|
+
partial = lines.pop();
|
|
473
|
+
for (let line of lines) {
|
|
474
|
+
var _entry2;
|
|
475
|
+
let entry;
|
|
476
|
+
/* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
|
|
477
|
+
try {
|
|
478
|
+
entry = JSON.parse(line);
|
|
479
|
+
} catch {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let key = this._snapshotKey((_entry2 = entry) === null || _entry2 === void 0 ? void 0 : _entry2.meta);
|
|
483
|
+
if (!key) continue;
|
|
484
|
+
let arr = this.cache.get(key);
|
|
485
|
+
if (!arr) this.cache.set(key, arr = []);
|
|
486
|
+
arr.push(entry);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
this.cacheCursor = this.diskSize;
|
|
490
|
+
} finally {
|
|
491
|
+
fs.closeSync(fd);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Streams the JSONL once and returns matching entries. Each call parses
|
|
496
|
+
// afresh — no parsed-entry cache, so RSS at upload time stays bounded by
|
|
497
|
+
// the size of the filtered result rather than the total log volume.
|
|
498
|
+
_scanDisk(filter) {
|
|
499
|
+
if (!this.diskPath || this.diskSize === 0) return [];
|
|
500
|
+
let result = [];
|
|
501
|
+
let fd = fs.openSync(this.diskPath, 'r');
|
|
502
|
+
try {
|
|
503
|
+
let buf = Buffer.alloc(READ_CHUNK_BYTES);
|
|
504
|
+
let offset = 0;
|
|
505
|
+
let partial = '';
|
|
506
|
+
while (offset < this.diskSize) {
|
|
507
|
+
let toRead = Math.min(READ_CHUNK_BYTES, this.diskSize - offset);
|
|
508
|
+
let n = fs.readSync(fd, buf, 0, toRead, offset);
|
|
509
|
+
offset += n;
|
|
510
|
+
let lines = (partial + buf.slice(0, n).toString('utf8')).split('\n');
|
|
511
|
+
partial = lines.pop();
|
|
512
|
+
for (let line of lines) {
|
|
513
|
+
/* istanbul ignore next: defensive — entries are JSON.stringify'd by us */
|
|
514
|
+
try {
|
|
515
|
+
let entry = JSON.parse(line);
|
|
516
|
+
if (filter(entry)) result.push(entry);
|
|
517
|
+
} catch {/* skip */}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} finally {
|
|
521
|
+
fs.closeSync(fd);
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
_fallbackToMemory(err) {
|
|
526
|
+
/* istanbul ignore else: latch — only fires once per build */
|
|
527
|
+
if (!this.writeFailureWarned) {
|
|
528
|
+
this.writeFailureWarned = true;
|
|
529
|
+
PercyLogger.stderr.write(`[percy] logger: disk write failed (${(err === null || err === void 0 ? void 0 : err.code) || (err === null || err === void 0 ? void 0 : err.message) || 'unknown'}), falling back to in-memory\n`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Read whatever we already wrote to disk into the fallback Set so /logs
|
|
533
|
+
// upload still includes everything from before the failure.
|
|
534
|
+
let existing = [];
|
|
535
|
+
if (this.diskPath && this.diskSize > 0) {
|
|
536
|
+
/* istanbul ignore next: defensive — _scanDisk handles its own errors */
|
|
537
|
+
try {
|
|
538
|
+
existing = this._scanDisk(() => true);
|
|
539
|
+
} catch {/* tolerate */}
|
|
540
|
+
}
|
|
541
|
+
this.diskMode = 'memory';
|
|
542
|
+
/* istanbul ignore next: defensive — fallback may already be set */
|
|
543
|
+
this.fallback ?? (this.fallback = new Set());
|
|
544
|
+
for (let entry of existing) this.fallback.add(entry);
|
|
545
|
+
this._drainBufferToMemory();
|
|
546
|
+
|
|
547
|
+
/* istanbul ignore else: latch — diskPath always set on first fallback */
|
|
548
|
+
if (this.diskPath) {
|
|
549
|
+
/* istanbul ignore next: defensive — best-effort cleanup */
|
|
550
|
+
try {
|
|
551
|
+
fs.unlinkSync(this.diskPath);
|
|
552
|
+
} catch {/* tolerate */}
|
|
553
|
+
this.diskPath = null;
|
|
554
|
+
}
|
|
555
|
+
this.diskSize = 0;
|
|
556
|
+
this.cache.clear();
|
|
557
|
+
this.cacheCursor = 0;
|
|
558
|
+
this.pendingFullScan.clear();
|
|
559
|
+
this.fallbackByKey = null;
|
|
560
|
+
}
|
|
561
|
+
_installExitHooks() {
|
|
562
|
+
var _process;
|
|
563
|
+
let active = (_process = process)[ACTIVE_INSTANCES] ?? (_process[ACTIVE_INSTANCES] = new Set());
|
|
564
|
+
active.add(this);
|
|
565
|
+
/* istanbul ignore if: latch — only the first install per process */
|
|
566
|
+
if (process[EXIT_HOOKS_INSTALLED]) return;
|
|
567
|
+
process[EXIT_HOOKS_INSTALLED] = true;
|
|
568
|
+
let cleanup = () => {
|
|
569
|
+
for (let logger of process[ACTIVE_INSTANCES]) logger._cleanup();
|
|
570
|
+
};
|
|
571
|
+
process.once('exit', cleanup);
|
|
572
|
+
process.once('beforeExit', cleanup);
|
|
573
|
+
// Why: SIGINT/SIGTERM are intentionally not handled. The CLI runtime
|
|
574
|
+
// already installs its own signal listeners; adding ours pushes past
|
|
575
|
+
// the default 10-listener limit and trips MaxListenersExceededWarning
|
|
576
|
+
// in downstream test suites. On Ctrl-C / runner kill our JSONL is left
|
|
577
|
+
// in os.tmpdir()/percy-logs/<pid>/ which the OS cleans, and the
|
|
578
|
+
// pid-scoped subdir prevents concurrent runs from colliding.
|
|
579
|
+
}
|
|
580
|
+
_cleanup() {
|
|
581
|
+
/* istanbul ignore next: defensive — flush should not throw */
|
|
582
|
+
try {
|
|
583
|
+
this._flushSync();
|
|
584
|
+
} catch {/* tolerate */}
|
|
585
|
+
if (this.diskPath) {
|
|
586
|
+
let dir = dirname(this.diskPath);
|
|
587
|
+
/* istanbul ignore next: defensive — best-effort */
|
|
588
|
+
try {
|
|
589
|
+
fs.unlinkSync(this.diskPath);
|
|
590
|
+
} catch {/* tolerate */}
|
|
591
|
+
// Best-effort rmdir of the per-pid subdir so long-lived runners don't
|
|
592
|
+
// accumulate empty directories. Fails harmlessly if peer instances of
|
|
593
|
+
// the same pid still hold files there.
|
|
594
|
+
/* istanbul ignore next: defensive — best-effort */
|
|
595
|
+
try {
|
|
596
|
+
fs.rmdirSync(dir);
|
|
597
|
+
} catch {/* tolerate */}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
231
600
|
}
|
|
232
601
|
export default PercyLogger;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/logger",
|
|
3
|
-
"version": "1.31.14-beta.
|
|
3
|
+
"version": "1.31.14-beta.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
"test": "node ../../scripts/test",
|
|
33
33
|
"test:coverage": "yarn test --coverage"
|
|
34
34
|
},
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "e4fce73023453b77cdef50aac1a9bd5eb70cd01a"
|
|
36
36
|
}
|
package/test/helpers.js
CHANGED
|
@@ -43,6 +43,14 @@ const helpers = {
|
|
|
43
43
|
},
|
|
44
44
|
|
|
45
45
|
async mock(options = {}) {
|
|
46
|
+
// Default to memory mode for downstream packages whose test setups don't
|
|
47
|
+
// explicitly select a mode. Logger tests that want disk mode set
|
|
48
|
+
// PERCY_LOGS_IN_MEMORY explicitly before calling mock(); the value here
|
|
49
|
+
// is only applied when the env hasn't already been pinned.
|
|
50
|
+
if (!('PERCY_LOGS_IN_MEMORY' in process.env)) {
|
|
51
|
+
process.env.PERCY_LOGS_IN_MEMORY = '1';
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
helpers.reset();
|
|
47
55
|
|
|
48
56
|
if (options.level) {
|
|
@@ -78,8 +86,13 @@ const helpers = {
|
|
|
78
86
|
},
|
|
79
87
|
|
|
80
88
|
reset(soft) {
|
|
81
|
-
if (soft)
|
|
82
|
-
|
|
89
|
+
if (soft) {
|
|
90
|
+
logger.loglevel('info');
|
|
91
|
+
} else {
|
|
92
|
+
// tear down the prior instance's disk artifacts before swapping it out
|
|
93
|
+
try { logger.constructor.instance?.reset(); } catch { /* tolerate */ }
|
|
94
|
+
delete logger.constructor.instance;
|
|
95
|
+
}
|
|
83
96
|
|
|
84
97
|
helpers.stdout.length = 0;
|
|
85
98
|
helpers.stderr.length = 0;
|
|
@@ -92,7 +105,7 @@ const helpers = {
|
|
|
92
105
|
},
|
|
93
106
|
|
|
94
107
|
dump() {
|
|
95
|
-
let msgs =
|
|
108
|
+
let msgs = logger.instance.query(() => true);
|
|
96
109
|
if (!msgs.length) return;
|
|
97
110
|
|
|
98
111
|
let log = m => process.env.__PERCY_BROWSERIFIED__ ? (
|