peekinside 1.2.1 → 1.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peekinside",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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/cli.js CHANGED
@@ -70,6 +70,9 @@ export class Cli {
70
70
  * @throws {Error} When the hub did not start in time
71
71
  */
72
72
  async start(openBrowser) {
73
+ // A hub left running by an older PeekInside is replaced by this version's (its data stays).
74
+ const replaced = await this.hub.replaceIfOlder();
75
+ if (replaced) console.log(`Restarted PeekInside Local: it was version ${replaced}, now ${VERSION}.`);
73
76
  let running = await this.hub.status();
74
77
  if (!running) {
75
78
  this.hub.spawn();
package/src/hub/client.js CHANGED
@@ -9,7 +9,7 @@ import fs from 'fs';
9
9
  import http from 'http';
10
10
  import path from 'path';
11
11
  import { fileURLToPath } from 'url';
12
- import { HUB_APP_ID, VERSION, settings } from '../config.js';
12
+ import { HUB_API_VERSION, HUB_APP_ID, VERSION, settings } from '../config.js';
13
13
 
14
14
  const HUB_ENTRY = fileURLToPath(new URL('./entry.js', import.meta.url));
15
15
 
@@ -153,6 +153,50 @@ export class HubClient {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * Replaces a running hub from an older PeekInside with this version's. Hubs outlive the apps
158
+ * that start them, so after an upgrade the old one would keep serving, its dashboard page
159
+ * included. Only a hub with the same API and data folder is replaced, so its data stays, and
160
+ * only the process hub.json names, so nothing else listening on the port is ever stopped.
161
+ *
162
+ * @returns {Promise<string|null>} The version that was replaced, or null when none was
163
+ */
164
+ async replaceIfOlder() {
165
+ const running = await this.status().catch(() => null);
166
+ const dataDir = settings.dataDir();
167
+ if (!HubClient.shouldReplace(running, { version: VERSION, dataDir, infoPid: readInfoPid(dataDir) })) return null;
168
+ try {
169
+ process.kill(running.pid, 'SIGTERM');
170
+ } catch (error) {
171
+ return null;
172
+ }
173
+ for (let attempt = 0; attempt < 50; attempt++) {
174
+ await new Promise((resolve) => setTimeout(resolve, 100));
175
+ if (!(await this.status().catch(() => null))) {
176
+ this.spawn();
177
+ await this.waitUntilReady(8000).catch(() => null);
178
+ return running.version;
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Whether a running hub is an older version than this one that may be replaced: same API, same
186
+ * data folder, and the process its hub.json names.
187
+ *
188
+ * @param {Object|null} running - Its GET /api/status
189
+ * @param {{ version: string, dataDir: string, infoPid: number|null }} self - This version, its
190
+ * data folder, and the pid in that folder's hub.json
191
+ * @returns {boolean}
192
+ */
193
+ static shouldReplace(running, { version, dataDir, infoPid }) {
194
+ if (!running || running.apiVersion !== HUB_API_VERSION) return false;
195
+ if (!Number.isInteger(running.pid) || running.pid !== infoPid || running.pid === process.pid) return false;
196
+ if (typeof running.dataDir !== 'string' || path.resolve(running.dataDir) !== path.resolve(dataDir)) return false;
197
+ return compareVersions(running.version, version) < 0;
198
+ }
199
+
156
200
  /**
157
201
  * Waits until the hub answers.
158
202
  *
@@ -182,3 +226,34 @@ export class HubClient {
182
226
  if (this.agent) this.agent.destroy();
183
227
  }
184
228
  }
229
+
230
+ // The pid in the data folder's hub.json (written by the hub that runs), or null.
231
+ function readInfoPid(dataDir) {
232
+ try {
233
+ const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'hub.json'), 'utf8'));
234
+ return Number.isInteger(info.pid) ? info.pid : null;
235
+ } catch (error) {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Compares two versions by major, minor and patch ("1.10.0" is after "1.9.3"). Anything after the
242
+ * patch number (a prerelease) is ignored, and versions that can't be read compare as equal, so
243
+ * nothing is replaced on a guess.
244
+ *
245
+ * @param {string} a
246
+ * @param {string} b
247
+ * @returns {number} Negative when a is older, positive when newer, 0 otherwise
248
+ */
249
+ export function compareVersions(a, b) {
250
+ const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(String(value || ''));
251
+ const left = parse(a);
252
+ const right = parse(b);
253
+ if (!left || !right) return 0;
254
+ for (let index = 1; index <= 3; index++) {
255
+ const difference = Number(left[index]) - Number(right[index]);
256
+ if (difference) return difference;
257
+ }
258
+ return 0;
259
+ }
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.
@@ -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
  }
@@ -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;
@@ -98,7 +101,13 @@ export class CloudTransport extends BatchTransport {
98
101
  // Never keeps the process alive on its own, and never runs as part of a request.
99
102
  this.timer = RequestContext.outside(() => setInterval(() => this.flush(), FLUSH_INTERVAL_MS));
100
103
  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().
104
+ // One more batch when the event loop empties, for scripts that end without calling flush():
105
+ // briefly, so a slow or unreachable PeekInside Cloud never holds the script up for long.
106
+ this.exiting = false;
107
+ this._onBeforeExit = () => {
108
+ this.exiting = true;
109
+ return this.flush();
110
+ };
102
111
  process.once('beforeExit', this._onBeforeExit);
103
112
 
104
113
  this._resendTimer = null;
@@ -235,10 +244,35 @@ export class CloudTransport extends BatchTransport {
235
244
  throw new Error(reason);
236
245
  }
237
246
 
247
+ // Ending while waiting to retry after a failure: what's queued is spooled rather than lost.
248
+ _onBackingOff() {
249
+ if (this.exiting) this._spoolQueue();
250
+ }
251
+
238
252
  _onRetry(error) {
253
+ if (this.exiting) {
254
+ // The script is ending: what's queued waits on disk for the next run.
255
+ this._spoolQueue();
256
+ return;
257
+ }
239
258
  this._warn(`Could not reach PeekInside Cloud (${error && error.message}). Retrying in ${Math.round(this.backoff / 1000)} s.`);
240
259
  }
241
260
 
261
+ // Seals the newest queued records (within the crash budget) and spools them, synchronously.
262
+ _spoolQueue() {
263
+ try {
264
+ const taken = this._takeNewest(SyncSealer.create(this.apiKey.publicKey, 'event'), MAX_QUEUE);
265
+ if (!taken.length) return;
266
+ const body = { events: [], exceptions: [] };
267
+ for (const { record, payload } of taken) (body[listFor(record)] ??= []).push(payload);
268
+ Spool.cloud(settings.dataDir(), this.apiKey.projectId).write({ createdAt: Date.now(), body });
269
+ // Sent by the next run; or by this one, should it keep running after all.
270
+ this._scheduleResend(SPOOL_RESEND_DELAY_MS);
271
+ } catch (error) {
272
+ // A data folder that can't be written loses the records, never the app.
273
+ }
274
+ }
275
+
242
276
  // An invalid key or an inactive plan loses every request from now on: an error, not a warning.
243
277
  _logDisabled(line) {
244
278
  console.error(line);
@@ -258,7 +292,7 @@ export class CloudTransport extends BatchTransport {
258
292
  method: 'POST',
259
293
  headers: this._headers(),
260
294
  body,
261
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
295
+ signal: AbortSignal.timeout(this.exiting ? EXIT_TIMEOUT_MS : REQUEST_TIMEOUT_MS)
262
296
  });
263
297
  }
264
298
 
@@ -79,6 +79,7 @@ export class LocalTransport extends BatchTransport {
79
79
  this.blocked = new BlockedRequests();
80
80
  this.blockedWarnings = new Set();
81
81
  this.outdatedHubNoted = false;
82
+ this.hubVersionChecked = false;
82
83
  }
83
84
 
84
85
  /**
@@ -198,6 +199,12 @@ export class LocalTransport extends BatchTransport {
198
199
  // Sends the batch with the blocked-request counts gathered since the last batch. Records are
199
200
  // dropped only when the hub refuses them; when it isn't reachable, both are kept for later.
200
201
  async _deliver(batch) {
202
+ // Once per process: a hub left running by an older PeekInside is replaced by this version's.
203
+ if (!this.hubVersionChecked) {
204
+ this.hubVersionChecked = true;
205
+ const replaced = await this.hub.replaceIfOlder().catch(() => null);
206
+ if (replaced) console.log(`${LOG_PREFIX} Restarted the local dashboard: it was PeekInside ${replaced}, and this app uses ${VERSION}.`);
207
+ }
201
208
  const blocked = this.blocked.take();
202
209
  const samples = this.takeSamples();
203
210
  const { requests, exceptions, jobs, logs } = BatchTransport.split(batch);