peekinside 1.2.2 → 1.2.4
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/package.json +1 -1
- package/src/spool.js +26 -0
- package/src/transport/batch.js +33 -1
- package/src/transport/cloud.js +93 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peekinside",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Debug Node.js from the inside: errors with the requests, queries, logs and jobs behind them. A free local dashboard, and encrypted monitoring for production",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
package/src/spool.js
CHANGED
|
@@ -20,6 +20,10 @@ import path from 'path';
|
|
|
20
20
|
export const CLOUD_FOLDER = 'cloud';
|
|
21
21
|
// A temporary file this old was left by a writer that died mid-write.
|
|
22
22
|
const STALE_TEMP_MS = 60_000;
|
|
23
|
+
// Most files, and bytes, one folder keeps: past either, the oldest files go. An app that crashes
|
|
24
|
+
// in a loop while its destination is down can't fill the disk.
|
|
25
|
+
const MAX_FILES = 100;
|
|
26
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* One spool folder.
|
|
@@ -76,9 +80,31 @@ export class Spool {
|
|
|
76
80
|
const temp = `${file}.${process.pid}.tmp`;
|
|
77
81
|
fs.writeFileSync(temp, JSON.stringify(content), { mode: 0o600 });
|
|
78
82
|
fs.renameSync(temp, file);
|
|
83
|
+
this._prune(name);
|
|
79
84
|
return name;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
// Deletes the oldest waiting files beyond MAX_FILES or MAX_BYTES, never the one just written.
|
|
88
|
+
_prune(keep) {
|
|
89
|
+
try {
|
|
90
|
+
const files = [];
|
|
91
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
92
|
+
if (!name.endsWith('.json')) continue;
|
|
93
|
+
const stat = fs.statSync(path.join(this.dir, name));
|
|
94
|
+
files.push({ name, at: stat.mtimeMs, size: stat.size });
|
|
95
|
+
}
|
|
96
|
+
if (files.length <= MAX_FILES && files.reduce((total, file) => total + file.size, 0) <= MAX_BYTES) return;
|
|
97
|
+
files.sort((a, b) => (a.name === keep ? -1 : b.name === keep ? 1 : b.at - a.at));
|
|
98
|
+
let total = 0;
|
|
99
|
+
files.forEach((file, index) => {
|
|
100
|
+
total += file.size;
|
|
101
|
+
if (file.name !== keep && (index >= MAX_FILES || total > MAX_BYTES)) fs.rmSync(path.join(this.dir, file.name), { force: true });
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// Pruning is housekeeping: a folder that can't be read is left as it is.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
/**
|
|
83
109
|
* The files waiting, oldest first. Files older than `maxAgeMs` are deleted instead, as are
|
|
84
110
|
* temporary files left by writers that died.
|
package/src/transport/batch.js
CHANGED
|
@@ -19,6 +19,15 @@ import { RequestRecord } from '../record.js';
|
|
|
19
19
|
|
|
20
20
|
// A destination that stays down must not flood the app's logs.
|
|
21
21
|
const WARNING_INTERVAL_MS = 60_000;
|
|
22
|
+
// While the destination is down, the queue is bounded by size as well as by number: a backlog of
|
|
23
|
+
// heavy requests (large bodies, long timelines) never holds more memory than this.
|
|
24
|
+
const MAX_QUEUE_BYTES = 32 * 1024 * 1024;
|
|
25
|
+
// Sizes are measured only once this many records wait (the most that can pass 32 MB unmeasured is
|
|
26
|
+
// this many of the largest records), so normal sending rarely measures anything.
|
|
27
|
+
const MEASURE_FROM = 50;
|
|
28
|
+
// A record whose size can't be measured counts as this much.
|
|
29
|
+
const UNKNOWN_SIZE = 16 * 1024;
|
|
30
|
+
const sizes = new WeakMap();
|
|
22
31
|
// Process samples kept while the destination is down: the newest win.
|
|
23
32
|
const MAX_SAMPLES = 60;
|
|
24
33
|
|
|
@@ -314,11 +323,34 @@ export class BatchTransport {
|
|
|
314
323
|
}
|
|
315
324
|
}
|
|
316
325
|
|
|
317
|
-
// Drops the oldest records beyond maxQueue.
|
|
326
|
+
// Drops the oldest records beyond maxQueue, and beyond MAX_QUEUE_BYTES once a backlog builds.
|
|
318
327
|
_trimQueue() {
|
|
319
328
|
if (this.queue.length > this.maxQueue) {
|
|
320
329
|
this.dropped += this.queue.length - this.maxQueue;
|
|
321
330
|
this.queue.splice(0, this.queue.length - this.maxQueue);
|
|
322
331
|
}
|
|
332
|
+
if (this.queue.length <= MEASURE_FROM) return;
|
|
333
|
+
let total = 0;
|
|
334
|
+
for (const record of this.queue) total += sizeOf(record);
|
|
335
|
+
let drop = 0;
|
|
336
|
+
while (total > MAX_QUEUE_BYTES && drop < this.queue.length - 1) total -= sizeOf(this.queue[drop++]);
|
|
337
|
+
if (drop) {
|
|
338
|
+
this.dropped += drop;
|
|
339
|
+
this.queue.splice(0, drop);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// About how much memory a queued record holds: its JSON size, measured once.
|
|
345
|
+
function sizeOf(record) {
|
|
346
|
+
let size = sizes.get(record);
|
|
347
|
+
if (size === undefined) {
|
|
348
|
+
try {
|
|
349
|
+
size = Buffer.byteLength(JSON.stringify(record));
|
|
350
|
+
} catch (error) {
|
|
351
|
+
size = UNKNOWN_SIZE;
|
|
352
|
+
}
|
|
353
|
+
sizes.set(record, size);
|
|
323
354
|
}
|
|
355
|
+
return size;
|
|
324
356
|
}
|
package/src/transport/cloud.js
CHANGED
|
@@ -26,6 +26,9 @@ const FLUSH_INTERVAL_MS = 1000;
|
|
|
26
26
|
const BATCH_SIZE = 100;
|
|
27
27
|
const MAX_QUEUE = 2000;
|
|
28
28
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
29
|
+
// When the event loop empties (a script ending), the last batch waits at most this long; what
|
|
30
|
+
// doesn't go is spooled for the next run instead.
|
|
31
|
+
const EXIT_TIMEOUT_MS = 2500;
|
|
29
32
|
// Sealed JSON per POST: records that don't fit wait for the next POST. PeekInside Cloud refuses
|
|
30
33
|
// bodies over 8 MB, and serverless hosts such as Vercel stop requests at 4.5 MB before they reach it.
|
|
31
34
|
const MAX_POST_BYTES = 4 * 1024 * 1024;
|
|
@@ -53,7 +56,16 @@ process.stdin.on('data', (chunk) => { input += chunk; });
|
|
|
53
56
|
process.stdin.on('end', () => {
|
|
54
57
|
let job;
|
|
55
58
|
try { job = JSON.parse(input); } catch (error) { process.exit(1); }
|
|
56
|
-
|
|
59
|
+
const signal = AbortSignal.timeout(${CRASH_SEND_TIMEOUT_MS - 500});
|
|
60
|
+
const post = (url) => fetch(url, { method: 'POST', headers: job.headers, body: job.body, redirect: 'manual', signal });
|
|
61
|
+
post(job.url)
|
|
62
|
+
.then((response) => {
|
|
63
|
+
const location = response.headers.get('location');
|
|
64
|
+
if ((response.status !== 307 && response.status !== 308) || !location) return response;
|
|
65
|
+
const target = new URL(location, job.url);
|
|
66
|
+
const ok = job.follow.hosts.includes(target.hostname) && job.follow.protocols.includes(target.protocol) && target.pathname === job.follow.path && (target.port === '' || target.port === job.follow.port);
|
|
67
|
+
return ok ? post(target.href) : response;
|
|
68
|
+
})
|
|
57
69
|
.then((response) => process.exit(response.ok ? ${SENT} : response.status === 400 || response.status === 413 ? ${REFUSED} : 2))
|
|
58
70
|
.catch(() => process.exit(1));
|
|
59
71
|
});
|
|
@@ -98,7 +110,13 @@ export class CloudTransport extends BatchTransport {
|
|
|
98
110
|
// Never keeps the process alive on its own, and never runs as part of a request.
|
|
99
111
|
this.timer = RequestContext.outside(() => setInterval(() => this.flush(), FLUSH_INTERVAL_MS));
|
|
100
112
|
if (typeof this.timer.unref === 'function') this.timer.unref();
|
|
101
|
-
// One more batch when the event loop empties, for scripts that end without calling flush()
|
|
113
|
+
// One more batch when the event loop empties, for scripts that end without calling flush():
|
|
114
|
+
// briefly, so a slow or unreachable PeekInside Cloud never holds the script up for long.
|
|
115
|
+
this.exiting = false;
|
|
116
|
+
this._onBeforeExit = () => {
|
|
117
|
+
this.exiting = true;
|
|
118
|
+
return this.flush();
|
|
119
|
+
};
|
|
102
120
|
process.once('beforeExit', this._onBeforeExit);
|
|
103
121
|
|
|
104
122
|
this._resendTimer = null;
|
|
@@ -235,10 +253,35 @@ export class CloudTransport extends BatchTransport {
|
|
|
235
253
|
throw new Error(reason);
|
|
236
254
|
}
|
|
237
255
|
|
|
256
|
+
// Ending while waiting to retry after a failure: what's queued is spooled rather than lost.
|
|
257
|
+
_onBackingOff() {
|
|
258
|
+
if (this.exiting) this._spoolQueue();
|
|
259
|
+
}
|
|
260
|
+
|
|
238
261
|
_onRetry(error) {
|
|
262
|
+
if (this.exiting) {
|
|
263
|
+
// The script is ending: what's queued waits on disk for the next run.
|
|
264
|
+
this._spoolQueue();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
239
267
|
this._warn(`Could not reach PeekInside Cloud (${error && error.message}). Retrying in ${Math.round(this.backoff / 1000)} s.`);
|
|
240
268
|
}
|
|
241
269
|
|
|
270
|
+
// Seals the newest queued records (within the crash budget) and spools them, synchronously.
|
|
271
|
+
_spoolQueue() {
|
|
272
|
+
try {
|
|
273
|
+
const taken = this._takeNewest(SyncSealer.create(this.apiKey.publicKey, 'event'), MAX_QUEUE);
|
|
274
|
+
if (!taken.length) return;
|
|
275
|
+
const body = { events: [], exceptions: [] };
|
|
276
|
+
for (const { record, payload } of taken) (body[listFor(record)] ??= []).push(payload);
|
|
277
|
+
Spool.cloud(settings.dataDir(), this.apiKey.projectId).write({ createdAt: Date.now(), body });
|
|
278
|
+
// Sent by the next run; or by this one, should it keep running after all.
|
|
279
|
+
this._scheduleResend(SPOOL_RESEND_DELAY_MS);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// A data folder that can't be written loses the records, never the app.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
242
285
|
// An invalid key or an inactive plan loses every request from now on: an error, not a warning.
|
|
243
286
|
_logDisabled(line) {
|
|
244
287
|
console.error(line);
|
|
@@ -253,13 +296,21 @@ export class CloudTransport extends BatchTransport {
|
|
|
253
296
|
};
|
|
254
297
|
}
|
|
255
298
|
|
|
256
|
-
|
|
257
|
-
|
|
299
|
+
// Posts to the endpoint. A redirect to the site's twin (see redirectTarget) is followed here,
|
|
300
|
+
// with the API key, and remembered; fetch() would follow it but drop the key.
|
|
301
|
+
async _post(body) {
|
|
302
|
+
const post = (url) => fetch(url, {
|
|
258
303
|
method: 'POST',
|
|
259
304
|
headers: this._headers(),
|
|
260
305
|
body,
|
|
261
|
-
|
|
306
|
+
redirect: 'manual',
|
|
307
|
+
signal: AbortSignal.timeout(this.exiting ? EXIT_TIMEOUT_MS : REQUEST_TIMEOUT_MS)
|
|
262
308
|
});
|
|
309
|
+
const response = await post(this.endpoint);
|
|
310
|
+
const moved = redirectTarget(response, this.endpoint);
|
|
311
|
+
if (!moved) return response;
|
|
312
|
+
this.endpoint = moved;
|
|
313
|
+
return post(moved);
|
|
263
314
|
}
|
|
264
315
|
|
|
265
316
|
// The newest queued records, sealed, within the crash budget; they leave the queue.
|
|
@@ -280,7 +331,7 @@ export class CloudTransport extends BatchTransport {
|
|
|
280
331
|
// Posts from a child process and waits for it: the only way to send over the network
|
|
281
332
|
// synchronously. The secret travels on stdin, never on the command line.
|
|
282
333
|
_sendNow(body) {
|
|
283
|
-
const input = JSON.stringify({ url: this.endpoint, headers: this._headers(), body });
|
|
334
|
+
const input = JSON.stringify({ url: this.endpoint, headers: this._headers(), body, follow: followRule(this.endpoint) });
|
|
284
335
|
const env = process.platform === 'win32' && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {};
|
|
285
336
|
const result = spawnSync(process.execPath, ['-e', SENDER], {
|
|
286
337
|
input,
|
|
@@ -361,3 +412,39 @@ function listFor(record) {
|
|
|
361
412
|
if (record instanceof LogRecord) return 'logs';
|
|
362
413
|
return 'events';
|
|
363
414
|
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Where a 307 or 308 answer from PeekInside Cloud moves its endpoint, when the API key may follow
|
|
418
|
+
* it there, or null. Hosting often sends a site's bare domain to its www name, or back (Vercel
|
|
419
|
+
* does by default), and fetch() drops the Authorization header on such a redirect, so it is
|
|
420
|
+
* followed by hand, and only to the same path on the same host or its www twin, never downgrading
|
|
421
|
+
* https. Anywhere else, the answer stands (and the batch is retried later).
|
|
422
|
+
*
|
|
423
|
+
* @param {Response} response
|
|
424
|
+
* @param {string} from - The URL that answered
|
|
425
|
+
* @returns {string|null}
|
|
426
|
+
*/
|
|
427
|
+
export function redirectTarget(response, from) {
|
|
428
|
+
if (response.status !== 307 && response.status !== 308) return null;
|
|
429
|
+
const location = response.headers.get('location');
|
|
430
|
+
if (!location) return null;
|
|
431
|
+
let target;
|
|
432
|
+
try {
|
|
433
|
+
target = new URL(location, from);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const rule = followRule(from);
|
|
438
|
+
const ok = rule.hosts.includes(target.hostname) && rule.protocols.includes(target.protocol) && target.pathname === rule.path && (target.port === '' || target.port === rule.port);
|
|
439
|
+
return ok ? target.href : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// What redirectTarget accepts, as data, so the crash sender's child process applies the same rule.
|
|
443
|
+
function followRule(from) {
|
|
444
|
+
const url = new URL(from);
|
|
445
|
+
const host = url.hostname;
|
|
446
|
+
const named = host.includes('.') && !host.startsWith('[') && !/^[\d.]+$/.test(host) && host !== 'localhost' && !host.endsWith('.localhost');
|
|
447
|
+
const hosts = [host];
|
|
448
|
+
if (named) hosts.push(host.startsWith('www.') ? host.slice(4) : `www.${host}`);
|
|
449
|
+
return { hosts, protocols: url.protocol === 'http:' ? ['http:', 'https:'] : [url.protocol], path: url.pathname, port: url.port };
|
|
450
|
+
}
|