peekinside 0.0.0-stage → 1.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * One project's requests and exceptions on disk, in its folder under <data dir>/projects:
3
+ *
4
+ * project.json name, folder, timestamps
5
+ * 00000001.jsonl request details, one JSON object per line
6
+ * 00000001.idx one request summary per line, with the offset and length of its detail
7
+ * exceptions/00000001.* exceptions, in the same format
8
+ * exception-state.json fingerprints the dashboard marked resolved
9
+ * jobs/00000001.* job runs, in the same format
10
+ * logs/00000001.* log lines: the index keeps the start of each line, the details the rest
11
+ *
12
+ * Process samples (memory, CPU, event-loop delay) are kept in memory only, for the Runtime page:
13
+ * the last six hours of each of the project's recent processes.
14
+ *
15
+ * Both logs are SegmentLogs (see segment-log.js): summaries in memory, details read on demand,
16
+ * retention by deleting whole segment files. The project keeps its newest `maxRequests` requests
17
+ * and `maxExceptions` exceptions.
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { ExceptionState } from './exception-state.js';
23
+ import { SegmentLog } from './segment-log.js';
24
+
25
+ // A project is online while one of its processes has checked in recently.
26
+ const ONLINE_MS = 25_000;
27
+ // Processes that stopped checking in are forgotten after this long.
28
+ const INSTANCE_TTL_MS = 60_000;
29
+ // How long the dashboard keeps saying that server requests were not recorded.
30
+ const BLOCKED_NOTICE_MS = 24 * 60 * 60 * 1000;
31
+ const MAX_BLOCKED_ENTRIES = 10;
32
+ // Samples kept per process (six hours at one every 10 seconds), and processes kept per project.
33
+ const MAX_RUNTIME_SAMPLES = 2160;
34
+ const MAX_RUNTIME_PROCESSES = 8;
35
+ // Fields a summary may contain, in the order they are written.
36
+ const SUMMARY_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'method', 'status', 'duration', 'requestSize', 'responseSize', 'aborted', 'exceptionCount', 'stats', 'url', 'path', 'contentType', 'user'];
37
+ const EXCEPTION_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'handled', 'fatal', 'requestId', 'jobRunId', 'name', 'message', 'code', 'fingerprint', 'mechanism', 'location', 'request', 'job', 'user'];
38
+ const JOB_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'name', 'queue', 'schedule', 'timezone', 'trigger', 'status', 'duration', 'attempt', 'exceptionCount', 'stats', 'error', 'user'];
39
+ const LOG_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'level', 'source', 'requestId', 'jobRunId', 'message'];
40
+ const EXCEPTIONS_FOLDER = 'exceptions';
41
+ const JOBS_FOLDER = 'jobs';
42
+ const LOGS_FOLDER = 'logs';
43
+
44
+ /**
45
+ * @typedef {Object} ProjectInfo
46
+ * @property {string} id
47
+ * @property {string} name
48
+ * @property {string} root - The project's folder on this machine
49
+ * @property {number} createdAt
50
+ * @property {number} lastSeenAt - When one of its processes last checked in
51
+ * @property {number} seqBase - Lowest sequence number the next request may get, kept so
52
+ * numbering continues after the requests are cleared
53
+ * @property {number} exceptionSeqBase - The same, for exceptions
54
+ * @property {{ total: number, firstAt: number, lastAt: number, entries: Object[] }} [blocked] -
55
+ * Server requests that were not recorded
56
+ */
57
+
58
+ /**
59
+ * The requests, exceptions, metadata and running processes of one project.
60
+ */
61
+ export class ProjectLog {
62
+ /**
63
+ * Use ProjectLog.create() or ProjectLog.load().
64
+ *
65
+ * @param {string} dir - The project's folder
66
+ * @param {ProjectInfo} info
67
+ * @param {Object} limits
68
+ * @param {number} limits.maxRequests - Requests kept
69
+ * @param {number} limits.segmentSize - Requests per segment file
70
+ * @param {number} limits.maxExceptions - Exceptions kept
71
+ * @param {number} limits.exceptionSegmentSize - Exceptions per segment file
72
+ * @param {number} limits.maxJobs - Job runs kept
73
+ * @param {number} limits.maxLogs - Log lines kept
74
+ * @param {{ requests?: SegmentLog, exceptions?: SegmentLog, jobs?: SegmentLog, logs?: SegmentLog }} [logs] - Logs
75
+ * read from disk
76
+ */
77
+ constructor(dir, info, limits, logs = {}) {
78
+ this.dir = dir;
79
+ this.info = info;
80
+ this.maxRequests = limits.maxRequests;
81
+ this.requests = logs.requests || new SegmentLog(dir, ProjectLog._requestOptions(info, limits));
82
+ this.exceptions = logs.exceptions || new SegmentLog(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits));
83
+ this.jobs = logs.jobs || new SegmentLog(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits));
84
+ this.logs = logs.logs || new SegmentLog(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits));
85
+ this.exceptionState = new ExceptionState(dir);
86
+ // Processes running the project, by pid. In memory only: they check in again after a restart.
87
+ this.instances = new Map();
88
+ // Recent processes and their samples, by pid, least recently seen first. In memory only.
89
+ this.runtime = new Map();
90
+ // Whether info changed since it was saved.
91
+ this.dirty = false;
92
+ }
93
+
94
+ /**
95
+ * Creates a project's folder and metadata.
96
+ *
97
+ * @param {string} dir - The project's folder
98
+ * @param {{ id: string, name: string, root: string }} info
99
+ * @param {number} now
100
+ * @param {Object} limits - See the constructor
101
+ * @returns {ProjectLog}
102
+ */
103
+ static create(dir, info, now, limits) {
104
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
105
+ const project = new ProjectLog(dir, {
106
+ id: info.id,
107
+ name: info.name,
108
+ root: info.root,
109
+ createdAt: now,
110
+ lastSeenAt: now,
111
+ seqBase: 1,
112
+ exceptionSeqBase: 1,
113
+ jobSeqBase: 1,
114
+ logSeqBase: 1
115
+ }, limits);
116
+ project.save();
117
+ return project;
118
+ }
119
+
120
+ /**
121
+ * Reads a project from its folder. Index lines cut short by a crash are skipped.
122
+ *
123
+ * @param {string} dir - The project's folder
124
+ * @param {string} id - Project id (the folder name)
125
+ * @param {Object} limits - See the constructor
126
+ * @returns {ProjectLog}
127
+ * @throws {Error} When project.json is missing or unreadable
128
+ */
129
+ static load(dir, id, limits) {
130
+ const saved = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8'));
131
+ const info = {
132
+ id,
133
+ name: typeof saved.name === 'string' ? saved.name : id,
134
+ root: typeof saved.root === 'string' ? saved.root : '',
135
+ createdAt: Number(saved.createdAt) || Date.now(),
136
+ lastSeenAt: Number(saved.lastSeenAt) || Number(saved.createdAt) || Date.now(),
137
+ seqBase: Number(saved.seqBase) || 1,
138
+ exceptionSeqBase: Number(saved.exceptionSeqBase) || 1,
139
+ jobSeqBase: Number(saved.jobSeqBase) || 1,
140
+ logSeqBase: Number(saved.logSeqBase) || 1,
141
+ ...(isBlockedInfo(saved.blocked) ? { blocked: saved.blocked } : {})
142
+ };
143
+ return new ProjectLog(dir, info, limits, {
144
+ requests: SegmentLog.load(dir, ProjectLog._requestOptions(info, limits)),
145
+ exceptions: SegmentLog.load(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits)),
146
+ jobs: SegmentLog.load(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits)),
147
+ logs: SegmentLog.load(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits))
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Records that one of the project's processes checked in, and keeps the name and folder it
153
+ * reports. Those rarely change, so they are saved right away; the check-in time is saved
154
+ * later by persist().
155
+ *
156
+ * @param {{ name: string, root: string }} info
157
+ * @param {Object} instance - Process details: pid, port, framework, node, sdk, environment, startedAt
158
+ * @param {number} now
159
+ * @returns {void}
160
+ */
161
+ checkIn(info, instance, now) {
162
+ this.rename(info);
163
+ this.info.lastSeenAt = now;
164
+ this.dirty = true;
165
+ this.instances.set(instance.pid, { ...instance, lastSeenAt: now });
166
+ }
167
+
168
+ /**
169
+ * Adds samples of one of the project's processes (memory, CPU, event-loop delay).
170
+ *
171
+ * @param {Object} instance - Process details, as checked in
172
+ * @param {Object[]} samples - Validated samples, oldest first
173
+ * @param {number} now
174
+ * @returns {void}
175
+ */
176
+ addSamples(instance, samples, now) {
177
+ if (!samples.length) return;
178
+ let entry = this.runtime.get(instance.pid);
179
+ // A restarted process can reuse a pid: it starts its own history.
180
+ if (entry && entry.instance.startedAt !== instance.startedAt) entry = null;
181
+ if (!entry) entry = { instance: { ...instance }, samples: [] };
182
+ this.runtime.delete(instance.pid);
183
+ this.runtime.set(instance.pid, entry);
184
+ entry.instance = { ...instance };
185
+ entry.lastSeenAt = now;
186
+ const last = entry.samples.length ? entry.samples[entry.samples.length - 1].at : -Infinity;
187
+ for (const sample of samples) {
188
+ if (sample.at > last) entry.samples.push(sample);
189
+ }
190
+ if (entry.samples.length > MAX_RUNTIME_SAMPLES) entry.samples.splice(0, entry.samples.length - MAX_RUNTIME_SAMPLES);
191
+ while (this.runtime.size > MAX_RUNTIME_PROCESSES) this.runtime.delete(this.runtime.keys().next().value);
192
+ }
193
+
194
+ /**
195
+ * The project's recent processes with their samples, the most recently seen first.
196
+ *
197
+ * @param {number} now
198
+ * @returns {{ processes: Object[] }}
199
+ */
200
+ runtimeHistory(now) {
201
+ const processes = [...this.runtime.values()].reverse().map((entry) => ({
202
+ ...entry.instance,
203
+ lastSeenAt: entry.lastSeenAt,
204
+ online: now - entry.lastSeenAt <= ONLINE_MS,
205
+ samples: entry.samples
206
+ }));
207
+ return { processes };
208
+ }
209
+
210
+ /**
211
+ * Keeps the name and folder a process reports, without counting it as a check-in (records
212
+ * spooled by a process that has exited).
213
+ *
214
+ * @param {{ name: string, root: string }} info
215
+ * @returns {void}
216
+ */
217
+ rename(info) {
218
+ if (this.info.name !== info.name || this.info.root !== info.root) {
219
+ this.info.name = info.name;
220
+ this.info.root = info.root;
221
+ this.save();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Adds counts of server requests that were not recorded. Proxied clients share one entry,
227
+ * holding the latest address as an example; only the largest entries are kept.
228
+ *
229
+ * @param {{ kind: 'host'|'client', value: string, count: number }[]} entries
230
+ * @param {number} now
231
+ * @returns {void}
232
+ */
233
+ noteBlocked(entries, now) {
234
+ const blocked = this.info.blocked || { total: 0, firstAt: now, lastAt: now, entries: [] };
235
+ for (const entry of entries) {
236
+ blocked.total += entry.count;
237
+ const existing = blocked.entries.find((item) => item.kind === entry.kind && (entry.kind === 'client' || item.value === entry.value));
238
+ if (existing) {
239
+ existing.count += entry.count;
240
+ existing.value = entry.value;
241
+ } else {
242
+ blocked.entries.push({ ...entry });
243
+ }
244
+ }
245
+ blocked.entries.sort((a, b) => b.count - a.count).splice(MAX_BLOCKED_ENTRIES);
246
+ blocked.lastAt = now;
247
+ this.info.blocked = blocked;
248
+ this.dirty = true;
249
+ }
250
+
251
+ /**
252
+ * Stores new requests. Requests already stored (same id) are skipped, so retried batches are
253
+ * harmless.
254
+ *
255
+ * @param {Object[]} records - Validated records, with their detail
256
+ * @returns {Object[]} Summaries of the stored requests
257
+ */
258
+ append(records) {
259
+ return this.requests.append(records);
260
+ }
261
+
262
+ /**
263
+ * Request summaries, newest first; oldest first when `after` is given (for polling new requests).
264
+ *
265
+ * @param {{ after?: number, before?: number, limit: number }} query - Sequence numbers to page by
266
+ * @returns {{ requests: Object[], hasMore: boolean }}
267
+ */
268
+ list(query) {
269
+ const { entries, hasMore } = this.requests.list(query);
270
+ return { requests: entries, hasMore };
271
+ }
272
+
273
+ /**
274
+ * The full detail of one request, read from disk.
275
+ *
276
+ * @param {string} requestId
277
+ * @returns {Object|null} null when it is no longer stored
278
+ */
279
+ detail(requestId) {
280
+ return this.requests.detail(requestId);
281
+ }
282
+
283
+ /**
284
+ * Deletes all requests, keeping the project.
285
+ *
286
+ * @returns {void}
287
+ */
288
+ clear() {
289
+ this.requests.clear();
290
+ // Sequence numbers keep increasing, so open dashboards keep polling from the right place.
291
+ this.info.seqBase = this.requests.seqBase;
292
+ this.save();
293
+ }
294
+
295
+ /**
296
+ * Stores new exceptions; ones already stored (same id) are skipped.
297
+ *
298
+ * @param {Object[]} records - Validated exceptions, with their detail
299
+ * @returns {Object[]} Summaries of the stored exceptions
300
+ */
301
+ appendExceptions(records) {
302
+ return this.exceptions.append(records);
303
+ }
304
+
305
+ /**
306
+ * Exception summaries, newest first; oldest first when `after` is given.
307
+ *
308
+ * @param {{ after?: number, before?: number, limit: number }} query
309
+ * @returns {{ exceptions: Object[], hasMore: boolean }}
310
+ */
311
+ listExceptions(query) {
312
+ const { entries, hasMore } = this.exceptions.list(query);
313
+ return { exceptions: entries, hasMore };
314
+ }
315
+
316
+ /**
317
+ * The full detail of one exception, read from disk.
318
+ *
319
+ * @param {string} exceptionId
320
+ * @returns {Object|null} null when it is no longer stored
321
+ */
322
+ exceptionDetail(exceptionId) {
323
+ return this.exceptions.detail(exceptionId);
324
+ }
325
+
326
+ /**
327
+ * Deletes all exceptions, keeping the project (and which issues are resolved).
328
+ *
329
+ * @returns {void}
330
+ */
331
+ clearExceptions() {
332
+ this.exceptions.clear();
333
+ this.info.exceptionSeqBase = this.exceptions.seqBase;
334
+ this.save();
335
+ }
336
+
337
+ /**
338
+ * Stores new job runs; ones already stored (same id) are skipped.
339
+ *
340
+ * @param {Object[]} records - Validated job runs, with their detail
341
+ * @returns {Object[]} Summaries of the stored runs
342
+ */
343
+ appendJobs(records) {
344
+ return this.jobs.append(records);
345
+ }
346
+
347
+ /**
348
+ * Job run summaries, newest first; oldest first when `after` is given.
349
+ *
350
+ * @param {{ after?: number, before?: number, limit: number }} query
351
+ * @returns {{ jobs: Object[], hasMore: boolean }}
352
+ */
353
+ listJobs(query) {
354
+ const { entries, hasMore } = this.jobs.list(query);
355
+ return { jobs: entries, hasMore };
356
+ }
357
+
358
+ /**
359
+ * @param {string} jobRunId
360
+ * @returns {Object|null}
361
+ */
362
+ jobDetail(jobRunId) {
363
+ return this.jobs.detail(jobRunId);
364
+ }
365
+
366
+ /**
367
+ * Deletes all job runs.
368
+ *
369
+ * @returns {void}
370
+ */
371
+ clearJobs() {
372
+ this.jobs.clear();
373
+ this.info.jobSeqBase = this.jobs.seqBase;
374
+ this.save();
375
+ }
376
+
377
+ /**
378
+ * Stores new log lines; ones already stored (same id) are skipped.
379
+ *
380
+ * @param {Object[]} records - Validated lines, with their detail (the whole line)
381
+ * @returns {Object[]}
382
+ */
383
+ appendLogs(records) {
384
+ return this.logs.append(records);
385
+ }
386
+
387
+ /**
388
+ * Log line summaries, newest first; oldest first when `after` is given.
389
+ *
390
+ * @param {{ after?: number, before?: number, limit: number }} query
391
+ * @returns {{ logs: Object[], hasMore: boolean }}
392
+ */
393
+ listLogs(query) {
394
+ const { entries, hasMore } = this.logs.list(query);
395
+ return { logs: entries, hasMore };
396
+ }
397
+
398
+ /**
399
+ * The whole of one log line (its message in full and its fields).
400
+ *
401
+ * @param {string} logId
402
+ * @returns {Object|null}
403
+ */
404
+ logDetail(logId) {
405
+ return this.logs.detail(logId);
406
+ }
407
+
408
+ /**
409
+ * Deletes all log lines.
410
+ *
411
+ * @returns {void}
412
+ */
413
+ clearLogs() {
414
+ this.logs.clear();
415
+ this.info.logSeqBase = this.logs.seqBase;
416
+ this.save();
417
+ }
418
+
419
+ /**
420
+ * Deletes the project's folder.
421
+ *
422
+ * @returns {void}
423
+ */
424
+ remove() {
425
+ fs.rmSync(this.dir, { recursive: true, force: true });
426
+ }
427
+
428
+ /**
429
+ * The project as the dashboard lists it. Processes that stopped checking in long ago are
430
+ * forgotten on the way.
431
+ *
432
+ * @param {number} now
433
+ * @returns {Object} id, name, root, timestamps, request and exception counts, whether it is
434
+ * online, its running processes, and the notice about server requests (null once it is older
435
+ * than a day)
436
+ */
437
+ describe(now) {
438
+ for (const [pid, instance] of this.instances) {
439
+ if (now - instance.lastSeenAt > INSTANCE_TTL_MS) this.instances.delete(pid);
440
+ }
441
+ const instances = [...this.instances.values()]
442
+ .filter((instance) => now - instance.lastSeenAt <= ONLINE_MS)
443
+ .sort((a, b) => b.startedAt - a.startedAt);
444
+ const newest = this.requests.newest;
445
+ const newestException = this.exceptions.newest;
446
+ const newestJob = this.jobs.newest;
447
+ return {
448
+ id: this.info.id,
449
+ name: this.info.name,
450
+ root: this.info.root,
451
+ createdAt: this.info.createdAt,
452
+ lastSeenAt: this.info.lastSeenAt,
453
+ lastRequestAt: newest ? newest.occurredAt : null,
454
+ requestCount: this.requests.size,
455
+ exceptionCount: this.exceptions.size,
456
+ lastExceptionAt: newestException ? newestException.occurredAt : null,
457
+ jobCount: this.jobs.size,
458
+ lastJobAt: newestJob ? newestJob.occurredAt : null,
459
+ logCount: this.logs.size,
460
+ online: instances.length > 0,
461
+ instances,
462
+ blocked: this.info.blocked && now - this.info.blocked.lastAt < BLOCKED_NOTICE_MS ? this.info.blocked : null
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Saves the metadata if it changed since the last save.
468
+ *
469
+ * @returns {void}
470
+ */
471
+ persist() {
472
+ if (this.dirty) this.save();
473
+ }
474
+
475
+ /**
476
+ * Saves the metadata. Written to a temporary file first, so a crash never leaves half a file.
477
+ *
478
+ * @returns {void}
479
+ */
480
+ save() {
481
+ const file = path.join(this.dir, 'project.json');
482
+ const temp = `${file}.${process.pid}.tmp`;
483
+ fs.writeFileSync(temp, `${JSON.stringify(this.info, null, 2)}\n`, { mode: 0o600 });
484
+ fs.renameSync(temp, file);
485
+ this.dirty = false;
486
+ }
487
+
488
+ /**
489
+ * Deletes the oldest segments of both logs while the newer ones still hold what is kept.
490
+ *
491
+ * @returns {void}
492
+ */
493
+ enforceRetention() {
494
+ this.requests.enforceRetention();
495
+ this.exceptions.enforceRetention();
496
+ this.jobs.enforceRetention();
497
+ this.logs.enforceRetention();
498
+ }
499
+
500
+ static _requestOptions(info, limits) {
501
+ return { maxEntries: limits.maxRequests, segmentSize: limits.segmentSize, seqBase: info.seqBase, pick: pickSummary };
502
+ }
503
+
504
+ static _jobOptions(info, limits) {
505
+ return { maxEntries: limits.maxJobs, segmentSize: Math.max(10, Math.ceil(limits.maxJobs / 4)), seqBase: info.jobSeqBase || 1, pick: pickJob };
506
+ }
507
+
508
+ static _logOptions(info, limits) {
509
+ return { maxEntries: limits.maxLogs, segmentSize: Math.max(10, Math.ceil(limits.maxLogs / 4)), seqBase: info.logSeqBase || 1, pick: pickLog };
510
+ }
511
+
512
+ static _exceptionOptions(info, limits) {
513
+ return {
514
+ maxEntries: limits.maxExceptions,
515
+ segmentSize: limits.exceptionSegmentSize,
516
+ seqBase: info.exceptionSeqBase,
517
+ pick: pickException
518
+ };
519
+ }
520
+ }
521
+
522
+ function isBlockedInfo(value) {
523
+ return Boolean(value) && Number.isFinite(value.total) && Number.isFinite(value.lastAt) && Array.isArray(value.entries);
524
+ }
525
+
526
+ // Only known fields, in a fixed order: index lines and API answers never carry anything else.
527
+ // Requests stored before exceptions existed count none; before timelines, they have no stats.
528
+ function pickSummary(source) {
529
+ const summary = {};
530
+ for (const field of SUMMARY_FIELDS) summary[field] = source[field];
531
+ if (summary.exceptionCount === undefined) summary.exceptionCount = 0;
532
+ if (summary.stats === undefined) summary.stats = null;
533
+ // Stored before releases (or users) were recorded.
534
+ if (summary.release === undefined) summary.release = null;
535
+ if (summary.user === undefined) summary.user = null;
536
+ return summary;
537
+ }
538
+
539
+ function pickException(source) {
540
+ const summary = {};
541
+ for (const field of EXCEPTION_FIELDS) summary[field] = source[field];
542
+ if (summary.release === undefined) summary.release = null;
543
+ if (summary.user === undefined) summary.user = null;
544
+ if (summary.jobRunId === undefined) summary.jobRunId = null;
545
+ if (summary.job === undefined) summary.job = null;
546
+ return summary;
547
+ }
548
+
549
+ function pickJob(source) {
550
+ const summary = {};
551
+ for (const field of JOB_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
552
+ return summary;
553
+ }
554
+
555
+ function pickLog(source) {
556
+ const summary = {};
557
+ for (const field of LOG_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
558
+ return summary;
559
+ }