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,324 @@
1
+ /**
2
+ * The delivery loop shared by the transports. Captured exchanges and exceptions become records in
3
+ * one bounded in-memory queue and are sent in batches, never on the request path.
4
+ *
5
+ * One batch is in flight at a time, and concurrent flushes share it. A batch that fails is put
6
+ * back at the front of the queue and sending backs off exponentially. When the queue is full the
7
+ * oldest records are dropped, so memory stays flat while the destination is down.
8
+ *
9
+ * A process that is crashing can't wait for the next batch: deliverNow() hands records over
10
+ * synchronously instead (each transport decides how).
11
+ */
12
+
13
+ import { LOG_PREFIX } from '../config.js';
14
+ import { ExceptionRecord } from '../exceptions/exception-record.js';
15
+ import { RequestContext } from '../exceptions/request-context.js';
16
+ import { JobRunRecord } from '../jobs/job-run-record.js';
17
+ import { LogRecord } from '../logs/log-record.js';
18
+ import { RequestRecord } from '../record.js';
19
+
20
+ // A destination that stays down must not flood the app's logs.
21
+ const WARNING_INTERVAL_MS = 60_000;
22
+ // Process samples kept while the destination is down: the newest win.
23
+ const MAX_SAMPLES = 60;
24
+
25
+ /**
26
+ * Abstract base of CloudTransport and LocalTransport.
27
+ *
28
+ * Subclasses implement _deliver(), which sends one batch, and decide when to flush through the
29
+ * scheduling hooks (_onQueued, _onBackingOff, _afterAttempt). The other hooks let them react to
30
+ * deliveries and failures.
31
+ */
32
+ export class BatchTransport {
33
+ /**
34
+ * @param {Object} options
35
+ * @param {string} options.environment - Normalized environment label, stored with every record
36
+ * @param {string|null} [options.release=null] - The running release, stored with every record
37
+ * @param {number} options.batchSize - Most records sent at once
38
+ * @param {number} options.maxQueue - Most records kept while waiting to be sent
39
+ * @param {number} options.firstBackoffMs - Wait after a first failure; it doubles with each
40
+ * further failure
41
+ * @param {number} options.maxBackoffMs - Longest wait between attempts
42
+ */
43
+ constructor({ environment, release = null, batchSize, maxQueue, firstBackoffMs, maxBackoffMs }) {
44
+ /**
45
+ * Environment label stored with every record
46
+ * @type {string}
47
+ */
48
+ this.environment = environment;
49
+ /**
50
+ * Release stored with every record (see release.js)
51
+ * @type {string|null}
52
+ */
53
+ this.release = release;
54
+ this.batchSize = batchSize;
55
+ this.maxQueue = maxQueue;
56
+ this.firstBackoffMs = firstBackoffMs;
57
+ this.maxBackoffMs = maxBackoffMs;
58
+
59
+ this.queue = [];
60
+ // The attempt in progress, shared by concurrent flush() calls, and the records it sends.
61
+ this.inflight = null;
62
+ this.sending = [];
63
+ // Set once the destination refused this process for good.
64
+ this.disabled = false;
65
+ // Current wait after consecutive failures, and the earliest time of the next attempt (ms).
66
+ this.backoff = 0;
67
+ this.nextAttempt = 0;
68
+ this.lastWarning = 0;
69
+ // Records dropped because the queue was full.
70
+ this.dropped = 0;
71
+ /**
72
+ * Process samples waiting to be sent (see RuntimeSampler), sent along with the next batch
73
+ * @type {import('../runtime/sampler.js').RuntimeSample[]}
74
+ */
75
+ this.samples = [];
76
+ // Registered by subclasses, each with its own policy; removed by stop().
77
+ this._onBeforeExit = () => this.flush();
78
+ }
79
+
80
+ /**
81
+ * Queues an exchange for sending. Never blocks and never throws for network reasons.
82
+ *
83
+ * @param {import('../capture/exchange-recorder.js').Exchange} exchange
84
+ * @returns {void}
85
+ */
86
+ enqueue(exchange) {
87
+ if (this.disabled) return;
88
+ this.enqueueRecord(RequestRecord.fromExchange(exchange, this.environment, this.release));
89
+ }
90
+
91
+ /**
92
+ * Queues a record that is already built, such as an exception. Requests and exceptions share
93
+ * the queue, its bound and its backoff.
94
+ *
95
+ * @param {RequestRecord|ExceptionRecord} record
96
+ * @returns {void}
97
+ */
98
+ enqueueRecord(record) {
99
+ if (this.disabled) return;
100
+ this.queue.push(record);
101
+ this._trimQueue();
102
+ // Timers and posts started from here must not belong to the request being recorded.
103
+ RequestContext.outside(() => this._onQueued());
104
+ }
105
+
106
+ /**
107
+ * Queues a process sample; it goes with the next batch.
108
+ *
109
+ * @param {import('../runtime/sampler.js').RuntimeSample} sample
110
+ * @returns {void}
111
+ */
112
+ addSample(sample) {
113
+ if (this.disabled) return;
114
+ this.samples.push(sample);
115
+ if (this.samples.length > MAX_SAMPLES) this.samples.splice(0, this.samples.length - MAX_SAMPLES);
116
+ RequestContext.outside(() => this._onQueued());
117
+ }
118
+
119
+ /**
120
+ * Takes the samples to send with a batch.
121
+ *
122
+ * @returns {import('../runtime/sampler.js').RuntimeSample[]}
123
+ */
124
+ takeSamples() {
125
+ return this.samples.splice(0);
126
+ }
127
+
128
+ /**
129
+ * Puts samples back after a batch failed, ahead of newer ones.
130
+ *
131
+ * @param {import('../runtime/sampler.js').RuntimeSample[]} samples
132
+ * @returns {void}
133
+ */
134
+ restoreSamples(samples) {
135
+ if (!samples.length || this.disabled) return;
136
+ this.samples.unshift(...samples);
137
+ if (this.samples.length > MAX_SAMPLES) this.samples.splice(0, this.samples.length - MAX_SAMPLES);
138
+ }
139
+
140
+ /**
141
+ * Delivers records synchronously, for a process that may exit as soon as this returns (a crash,
142
+ * or an app whose own error handler exits). Never throws. Abstract: the base class drops them.
143
+ *
144
+ * @param {Object} batch
145
+ * @param {ExceptionRecord[]} batch.exceptions - The exceptions to deliver
146
+ * @param {import('../capture/exchange-recorder.js').Exchange[]} batch.exchanges - Requests that
147
+ * were in progress, recorded as they stand
148
+ * @param {boolean} batch.fatal - The process is exiting: what is still queued goes too
149
+ * @returns {void}
150
+ */
151
+ deliverNow({ exceptions, exchanges, fatal }) {}
152
+
153
+ /**
154
+ * Splits records into the lists payloads have: requests, exceptions, job runs and log lines.
155
+ *
156
+ * @param {Array<RequestRecord|ExceptionRecord|JobRunRecord|LogRecord>} records
157
+ * @returns {{ requests: RequestRecord[], exceptions: ExceptionRecord[], jobs: JobRunRecord[], logs: LogRecord[] }}
158
+ */
159
+ static split(records) {
160
+ const requests = [];
161
+ const exceptions = [];
162
+ const jobs = [];
163
+ const logs = [];
164
+ for (const record of records) {
165
+ if (record instanceof ExceptionRecord) exceptions.push(record);
166
+ else if (record instanceof JobRunRecord) jobs.push(record);
167
+ else if (record instanceof LogRecord) logs.push(record);
168
+ else requests.push(record);
169
+ }
170
+ return { requests, exceptions, jobs, logs };
171
+ }
172
+
173
+ /**
174
+ * Sends one batch now, unless sending is backing off after a failure. Concurrent callers share
175
+ * the batch already in flight.
176
+ *
177
+ * @param {boolean} [force=false] - Send even when nothing is pending (a heartbeat)
178
+ * @returns {Promise<void>} Settles when the attempt is over; never rejects
179
+ */
180
+ flush(force = false) {
181
+ if (this.inflight) return this.inflight;
182
+ if (this.disabled || (!force && !this._hasPending())) return Promise.resolve();
183
+ if (Date.now() < this.nextAttempt) {
184
+ this._onBackingOff();
185
+ return Promise.resolve();
186
+ }
187
+ // Sending is PeekInside's own traffic, even when the app calls flush() during a request.
188
+ this.inflight = RequestContext.outside(() => this._sendBatch().finally(() => {
189
+ this.inflight = null;
190
+ this._afterAttempt();
191
+ }));
192
+ return this.inflight;
193
+ }
194
+
195
+ /**
196
+ * Sends everything queued, stopping early if sending fails.
197
+ *
198
+ * @returns {Promise<void>}
199
+ */
200
+ async drain() {
201
+ await this.inflight;
202
+ // A batch in flight took its records off the queue: wait for it too (a transport that sends
203
+ // in several parts starts the next one as soon as the last is done).
204
+ while ((this.queue.length || this.inflight) && !this.disabled) {
205
+ if (this.inflight) {
206
+ await this.inflight;
207
+ continue;
208
+ }
209
+ const before = this.queue.length;
210
+ await this.flush();
211
+ if (this.queue.length >= before && !this.inflight) break; // backing off after a failure
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Stops listening for the process exit. Subclasses also stop their timers.
217
+ *
218
+ * @returns {void}
219
+ */
220
+ stop() {
221
+ process.removeListener('beforeExit', this._onBeforeExit);
222
+ }
223
+
224
+ /**
225
+ * Sends one batch. Abstract.
226
+ *
227
+ * @param {Array<RequestRecord|ExceptionRecord>} batch
228
+ * @returns {Promise<boolean>} True once delivered; false when the destination refused the batch
229
+ * for good (the subclass has reported it, and the batch is dropped). Rejects when the batch
230
+ * should be retried later.
231
+ */
232
+ async _deliver(batch) {
233
+ throw new Error(`${this.constructor.name} does not implement _deliver()`);
234
+ }
235
+
236
+ /**
237
+ * @returns {boolean} Whether anything is waiting to be sent
238
+ */
239
+ _hasPending() {
240
+ return this.queue.length > 0 || this.samples.length > 0;
241
+ }
242
+
243
+ /** Scheduling hook: a record was queued. */
244
+ _onQueued() {}
245
+
246
+ /** Scheduling hook: flush() was called while backing off, so nothing was sent. */
247
+ _onBackingOff() {}
248
+
249
+ /** Scheduling hook: an attempt is over, delivered or not. */
250
+ _afterAttempt() {}
251
+
252
+ /** Called after a batch was delivered. */
253
+ _onDelivered() {}
254
+
255
+ /**
256
+ * Called after a failed batch was put back and the next attempt postponed (see `backoff`).
257
+ *
258
+ * @param {Error} error - Why the attempt failed
259
+ */
260
+ _onRetry(error) {}
261
+
262
+ /**
263
+ * Stops sending for good: the destination won't accept anything from this process.
264
+ *
265
+ * @param {string} message - Why, for the app's log
266
+ * @returns {void}
267
+ */
268
+ _disable(message) {
269
+ this.disabled = true;
270
+ this.queue = [];
271
+ this._logDisabled(`${LOG_PREFIX} ${message}`);
272
+ }
273
+
274
+ /**
275
+ * Logs why sending stopped.
276
+ *
277
+ * @param {string} line
278
+ */
279
+ _logDisabled(line) {
280
+ console.warn(line);
281
+ }
282
+
283
+ /**
284
+ * Logs a warning, at most once a minute.
285
+ *
286
+ * @param {string} message
287
+ * @returns {void}
288
+ */
289
+ _warn(message) {
290
+ if (Date.now() - this.lastWarning < WARNING_INTERVAL_MS) return;
291
+ this.lastWarning = Date.now();
292
+ console.warn(`${LOG_PREFIX} ${message}`);
293
+ }
294
+
295
+ // Sends the oldest records. A failed batch goes back to the front of the queue, so records
296
+ // keep their order, and the next attempt waits twice as long as the last one.
297
+ async _sendBatch() {
298
+ const batch = this.queue.splice(0, this.batchSize);
299
+ this.sending = batch;
300
+ try {
301
+ if (await this._deliver(batch)) {
302
+ this.backoff = 0;
303
+ this.nextAttempt = 0;
304
+ this._onDelivered();
305
+ }
306
+ } catch (error) {
307
+ this.queue.unshift(...batch);
308
+ this._trimQueue();
309
+ this.backoff = Math.min(this.maxBackoffMs, this.backoff ? this.backoff * 2 : this.firstBackoffMs);
310
+ this.nextAttempt = Date.now() + this.backoff;
311
+ this._onRetry(error);
312
+ } finally {
313
+ this.sending = [];
314
+ }
315
+ }
316
+
317
+ // Drops the oldest records beyond maxQueue.
318
+ _trimQueue() {
319
+ if (this.queue.length > this.maxQueue) {
320
+ this.dropped += this.queue.length - this.maxQueue;
321
+ this.queue.splice(0, this.queue.length - this.maxQueue);
322
+ }
323
+ }
324
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Sends captured exchanges and exceptions to PeekInside Cloud, encrypted with the project's public key.
3
+ *
4
+ * Records are queued in memory and sent in batches from a background timer, never on the request
5
+ * path. Network failures are retried with backoff; when the queue is full the oldest records are
6
+ * dropped. An invalid key or an inactive plan stops sending (logged once).
7
+ *
8
+ * A crashing process can't wait for the timer: the crash is sealed synchronously and posted from
9
+ * a short-lived child process the app waits for (spawnSync). If that fails, the sealed batch is
10
+ * spooled to disk and sent when the app starts again. Spooled data is ciphertext only.
11
+ */
12
+
13
+ import { spawnSync } from 'child_process';
14
+ import { randomUUID } from 'crypto';
15
+ import { settings } from '../config.js';
16
+ import { ApiKey, Sealer, SyncSealer } from '../crypto.js';
17
+ import { RequestContext } from '../exceptions/request-context.js';
18
+ import { ExceptionRecord } from '../exceptions/exception-record.js';
19
+ import { JobRunRecord } from '../jobs/job-run-record.js';
20
+ import { LogRecord } from '../logs/log-record.js';
21
+ import { RequestRecord } from '../record.js';
22
+ import { Spool } from '../spool.js';
23
+ import { BatchTransport } from './batch.js';
24
+
25
+ const FLUSH_INTERVAL_MS = 1000;
26
+ const BATCH_SIZE = 100;
27
+ const MAX_QUEUE = 2000;
28
+ const REQUEST_TIMEOUT_MS = 10_000;
29
+ // Sealed JSON per POST: records that don't fit wait for the next POST. PeekInside Cloud refuses
30
+ // bodies over 8 MB, and serverless hosts such as Vercel stop requests at 4.5 MB before they reach it.
31
+ const MAX_POST_BYTES = 4 * 1024 * 1024;
32
+ const FIRST_BACKOFF_MS = 1000;
33
+ const MAX_BACKOFF_MS = 60_000;
34
+ // A crash waits this long for its batch to be sent before spooling it.
35
+ const CRASH_SEND_TIMEOUT_MS = 3000;
36
+ // Queued records sent along with a crash, at most (sealed JSON); the crash itself always goes.
37
+ const CRASH_QUEUE_BUDGET = 1024 * 1024;
38
+ // Spooled batches are resent shortly after start, and given up after a week.
39
+ const SPOOL_RESEND_DELAY_MS = 1000;
40
+ const SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
41
+
42
+ // Exit codes of the sender: 0 delivered, 3 refused for good (sending it again would fail again),
43
+ // anything else failed (spool and retry later).
44
+ const SENT = 0;
45
+ const REFUSED = 3;
46
+
47
+ // Posts one batch, read as JSON from stdin, and exits. It runs with an empty environment, so the
48
+ // app's NODE_OPTIONS (--require hooks, loaders, --inspect) don't load into it.
49
+ const SENDER = `
50
+ let input = '';
51
+ process.stdin.setEncoding('utf8');
52
+ process.stdin.on('data', (chunk) => { input += chunk; });
53
+ process.stdin.on('end', () => {
54
+ let job;
55
+ try { job = JSON.parse(input); } catch (error) { process.exit(1); }
56
+ fetch(job.url, { method: 'POST', headers: job.headers, body: job.body, signal: AbortSignal.timeout(${CRASH_SEND_TIMEOUT_MS - 500}) })
57
+ .then((response) => process.exit(response.ok ? ${SENT} : response.status === 400 || response.status === 413 ? ${REFUSED} : 2))
58
+ .catch(() => process.exit(1));
59
+ });
60
+ `;
61
+
62
+ /**
63
+ * Seals records to the project's public key and posts them to `<site>/api/ingest`.
64
+ */
65
+ export class CloudTransport extends BatchTransport {
66
+ /**
67
+ * @param {Object} options
68
+ * @param {string} options.apiKey - Project API key from the PeekInside dashboard
69
+ * @param {string} [options.environment] - Label such as "production" or "staging"
70
+ * @param {string|null} [options.release] - The running release (see release.js)
71
+ * @param {string} [options.version] - SDK version, sent as the user agent
72
+ * @throws {Error} When the API key is not a valid PeekInside API key
73
+ */
74
+ constructor({ apiKey, environment, release = null, version }) {
75
+ const key = ApiKey.parse(apiKey);
76
+ if (!key) {
77
+ throw new Error('PEEKINSIDE_API_KEY is not a valid PeekInside API key');
78
+ }
79
+ super({
80
+ environment: RequestRecord.normalizeEnvironment(environment, 'production'),
81
+ release,
82
+ batchSize: BATCH_SIZE,
83
+ maxQueue: MAX_QUEUE,
84
+ firstBackoffMs: FIRST_BACKOFF_MS,
85
+ maxBackoffMs: MAX_BACKOFF_MS
86
+ });
87
+
88
+ this.apiKey = key;
89
+ this.endpoint = `${settings.siteUrl()}/api/ingest`;
90
+ this.userAgent = `peekinside-node/${version || 'dev'}`;
91
+ /**
92
+ * This process, as its samples are grouped: a random id, its pid and Node.js version (no host
93
+ * name, which stays sealed with exceptions)
94
+ * @type {{ id: string, pid: number, node: string, startedAt: number }}
95
+ */
96
+ this.instance = { id: randomUUID(), pid: process.pid, node: process.version, startedAt: Date.now() };
97
+
98
+ // Never keeps the process alive on its own, and never runs as part of a request.
99
+ this.timer = RequestContext.outside(() => setInterval(() => this.flush(), FLUSH_INTERVAL_MS));
100
+ 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().
102
+ process.once('beforeExit', this._onBeforeExit);
103
+
104
+ this._resendTimer = null;
105
+ this._resending = null;
106
+ // Set when records that didn't fit in the last POST wait for the next one.
107
+ this._more = false;
108
+ // Batches spooled by an earlier run that crashed.
109
+ this._scheduleResend(SPOOL_RESEND_DELAY_MS);
110
+ }
111
+
112
+ /**
113
+ * Stops the background timer and the flush at exit. Sending itself stays possible: drain()
114
+ * still sends what is queued.
115
+ *
116
+ * @returns {void}
117
+ */
118
+ stop() {
119
+ clearInterval(this.timer);
120
+ clearTimeout(this._resendTimer);
121
+ this._resendTimer = null;
122
+ super.stop();
123
+ }
124
+
125
+ /**
126
+ * Seals the records synchronously and sends them before returning. For a crash, they are posted
127
+ * from a child process the app waits for (up to 3 s), along with the newest queued records; when
128
+ * that fails, or the process keeps running, the sealed batch is spooled to disk and sent by the
129
+ * next run (or shortly, by this one).
130
+ *
131
+ * @param {Object} batch - See BatchTransport#deliverNow()
132
+ * @returns {void}
133
+ */
134
+ deliverNow({ exceptions, exchanges, fatal }) {
135
+ if (this.disabled) return;
136
+ try {
137
+ const sealer = SyncSealer.create(this.apiKey.publicKey, 'event');
138
+ const sealed = [];
139
+ for (const record of [...exceptions, ...exchanges.map((exchange) => RequestRecord.fromExchange(exchange, this.environment, this.release))]) {
140
+ sealed.push({ record, payload: record.sealSync(sealer) });
141
+ }
142
+ if (fatal) sealed.push(...this._takeNewest(sealer, BATCH_SIZE - sealed.length));
143
+
144
+ const body = { events: [], exceptions: [] };
145
+ for (const { record, payload } of sealed) {
146
+ const list = listFor(record);
147
+ (body[list] ??= []).push(payload);
148
+ }
149
+
150
+ if (fatal && this._sendNow(JSON.stringify(body)) !== 'failed') return;
151
+ Spool.cloud(settings.dataDir(), this.apiKey.projectId).write({ createdAt: Date.now(), body });
152
+ if (!fatal) this._scheduleResend(SPOOL_RESEND_DELAY_MS);
153
+ } catch (error) {
154
+ // A crash that can't be delivered is lost; the app's own crash is unaffected.
155
+ }
156
+ }
157
+
158
+ // Full batches go out right away instead of waiting for the timer.
159
+ _onQueued() {
160
+ if (this.queue.length >= this.batchSize) this.flush();
161
+ }
162
+
163
+ _afterAttempt() {
164
+ if (this.queue.length >= this.batchSize || this._more) {
165
+ this._more = false;
166
+ this.flush();
167
+ }
168
+ }
169
+
170
+ // Seals records in order until the POST would pass MAX_POST_BYTES; a single record always goes,
171
+ // even a large one, or it would block the queue.
172
+ async _pack(batch, sealer) {
173
+ const lists = { events: [], exceptions: [], jobs: [], logs: [] };
174
+ let size = 32;
175
+ let count = 0;
176
+ for (const record of batch) {
177
+ const sealed = await record.seal(sealer);
178
+ const length = Buffer.byteLength(JSON.stringify(sealed)) + 1;
179
+ if (count > 0 && size + length > MAX_POST_BYTES) break;
180
+ size += length;
181
+ count++;
182
+ lists[listFor(record)].push(sealed);
183
+ }
184
+ return { ...lists, rest: batch.slice(count) };
185
+ }
186
+
187
+ // Records that didn't fit go back to the front of the queue, in order, and go out right away.
188
+ _requeue(rest) {
189
+ if (!rest.length) return;
190
+ this.queue.unshift(...rest);
191
+ this._trimQueue();
192
+ this._more = true;
193
+ }
194
+
195
+ async _deliver(batch) {
196
+ // A fresh ephemeral key per batch: one key agreement, a new IV per message.
197
+ const sealer = await Sealer.create(this.apiKey.publicKey, 'event');
198
+ const { events, exceptions, jobs, logs, rest } = await this._pack(batch, sealer);
199
+ const body = { events };
200
+ if (exceptions.length) body.exceptions = exceptions;
201
+ if (jobs.length) body.jobs = jobs;
202
+ if (logs.length) body.logs = logs;
203
+ const samples = this.takeSamples();
204
+ if (samples.length) {
205
+ body.instance = { ...this.instance, environment: this.environment, release: this.release };
206
+ body.samples = samples;
207
+ }
208
+ let response;
209
+ try {
210
+ response = await this._post(JSON.stringify(body));
211
+ } catch (error) {
212
+ this.restoreSamples(samples);
213
+ throw error;
214
+ }
215
+ if (response.status >= 500) this.restoreSamples(samples);
216
+ const sent = batch.length - rest.length;
217
+
218
+ if (response.ok) {
219
+ this._requeue(rest);
220
+ return true;
221
+ }
222
+
223
+ const reason = await response.json().then((answer) => answer.error, () => '') || `HTTP ${response.status}`;
224
+ if (response.status === 401 || response.status === 402 || response.status === 403) {
225
+ this._disable(`${reason} Requests are no longer sent to PeekInside Cloud until the app restarts.`);
226
+ return false;
227
+ }
228
+ if (response.status === 400 || response.status === 413) {
229
+ // Retrying the same data would fail again; what didn't fit in this POST goes next.
230
+ this._warn(`PeekInside Cloud rejected a batch of ${sent} records: ${reason}`);
231
+ this._requeue(rest);
232
+ return false;
233
+ }
234
+ // Server errors are retried like network errors.
235
+ throw new Error(reason);
236
+ }
237
+
238
+ _onRetry(error) {
239
+ this._warn(`Could not reach PeekInside Cloud (${error && error.message}). Retrying in ${Math.round(this.backoff / 1000)} s.`);
240
+ }
241
+
242
+ // An invalid key or an inactive plan loses every request from now on: an error, not a warning.
243
+ _logDisabled(line) {
244
+ console.error(line);
245
+ }
246
+
247
+ _headers() {
248
+ return {
249
+ 'content-type': 'application/json',
250
+ authorization: `Bearer ${this.apiKey.secret}`,
251
+ 'x-peekinside-project': this.apiKey.projectId,
252
+ 'user-agent': this.userAgent
253
+ };
254
+ }
255
+
256
+ _post(body) {
257
+ return fetch(this.endpoint, {
258
+ method: 'POST',
259
+ headers: this._headers(),
260
+ body,
261
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
262
+ });
263
+ }
264
+
265
+ // The newest queued records, sealed, within the crash budget; they leave the queue.
266
+ _takeNewest(sealer, count) {
267
+ const taken = [];
268
+ let size = 0;
269
+ while (taken.length < count && this.queue.length > 0) {
270
+ const record = this.queue[this.queue.length - 1];
271
+ const payload = record.sealSync(sealer);
272
+ size += JSON.stringify(payload).length;
273
+ if (size > CRASH_QUEUE_BUDGET) break;
274
+ this.queue.pop();
275
+ taken.unshift({ record, payload });
276
+ }
277
+ return taken;
278
+ }
279
+
280
+ // Posts from a child process and waits for it: the only way to send over the network
281
+ // synchronously. The secret travels on stdin, never on the command line.
282
+ _sendNow(body) {
283
+ const input = JSON.stringify({ url: this.endpoint, headers: this._headers(), body });
284
+ const env = process.platform === 'win32' && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {};
285
+ const result = spawnSync(process.execPath, ['-e', SENDER], {
286
+ input,
287
+ env,
288
+ timeout: CRASH_SEND_TIMEOUT_MS,
289
+ stdio: ['pipe', 'ignore', 'ignore'],
290
+ windowsHide: true
291
+ });
292
+ if (result.status === SENT) return 'sent';
293
+ if (result.status === REFUSED) return 'refused';
294
+ return 'failed';
295
+ }
296
+
297
+ _scheduleResend(delay) {
298
+ if (this._resendTimer || this.disabled) return;
299
+ this._resendTimer = RequestContext.outside(() => setTimeout(() => {
300
+ this._resendTimer = null;
301
+ this.resendSpooled();
302
+ }, delay));
303
+ if (typeof this._resendTimer.unref === 'function') this._resendTimer.unref();
304
+ }
305
+
306
+ /**
307
+ * Sends batches spooled by crashes, oldest first, and deletes them once delivered (or refused
308
+ * for good). Batches older than a week are deleted unsent. Stops at the first failure: the rest
309
+ * waits for the next start.
310
+ *
311
+ * @returns {Promise<void>} Never rejects
312
+ */
313
+ resendSpooled() {
314
+ if (!this._resending) {
315
+ this._resending = this._resendSpooled().finally(() => {
316
+ this._resending = null;
317
+ });
318
+ }
319
+ return this._resending;
320
+ }
321
+
322
+ async _resendSpooled() {
323
+ try {
324
+ const spool = Spool.cloud(settings.dataDir(), this.apiKey.projectId);
325
+ for (const name of spool.list({ maxAgeMs: SPOOL_MAX_AGE_MS })) {
326
+ if (this.disabled) return;
327
+ // Cluster workers start together: only one of them sends each file.
328
+ const claimed = spool.claim(name);
329
+ if (!claimed) continue;
330
+ const entry = spool.read(claimed);
331
+ if (!entry || !entry.body || typeof entry.body !== 'object') {
332
+ spool.remove(claimed);
333
+ continue;
334
+ }
335
+
336
+ let response;
337
+ try {
338
+ response = await this._post(JSON.stringify(entry.body));
339
+ } catch (error) {
340
+ spool.release(claimed);
341
+ return;
342
+ }
343
+ if (response.body) response.body.cancel().catch(() => {});
344
+ if (response.ok || response.status === 400 || response.status === 413) {
345
+ spool.remove(claimed);
346
+ } else {
347
+ spool.release(claimed);
348
+ return;
349
+ }
350
+ }
351
+ } catch (error) {
352
+ // Spooled batches wait for the next start.
353
+ }
354
+ }
355
+ }
356
+
357
+ // The list of the request body a record goes in.
358
+ function listFor(record) {
359
+ if (record instanceof ExceptionRecord) return 'exceptions';
360
+ if (record instanceof JobRunRecord) return 'jobs';
361
+ if (record instanceof LogRecord) return 'logs';
362
+ return 'events';
363
+ }