peekinside 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +561 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- 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
|
+
}
|