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,642 @@
1
+ /**
2
+ * The timeline of one request: everything that happened while it was handled, as spans (database
3
+ * queries, outgoing HTTP calls, console lines, exceptions, custom measures, event-loop blocks),
4
+ * plus counters for lists and filters (see the timeline data contract).
5
+ *
6
+ * Instrumentations find the timeline of the code running now with Timeline.current() and record
7
+ * through it. The timeline closes when the request's record is built: what starts later (work
8
+ * the app keeps doing after the response) is ignored, and spans still open at that moment are kept
9
+ * without a duration.
10
+ *
11
+ * Queries are grouped into statements by fingerprint (the statement without its values), which is
12
+ * what N+1 detection, duplicate detection and the per-statement numbers are built on. Every
13
+ * collection is capped, and the serialized timeline stays within 512 KB, so a request that runs
14
+ * ten thousand queries costs a bounded amount of memory and bandwidth; the counters still count
15
+ * everything.
16
+ */
17
+
18
+ import { createHash } from 'crypto';
19
+ import { performance } from 'perf_hooks';
20
+ import { RequestContext } from '../exceptions/request-context.js';
21
+ import { Operation } from '../instrumentation/operation.js';
22
+ import { LoopMonitor } from '../runtime/loop-monitor.js';
23
+ import { Caller } from './caller.js';
24
+ import { ExecutionHash } from './execution-hash.js';
25
+ import { LruCache } from './lru.js';
26
+ import { Masker } from './masking.js';
27
+ import { SqlText } from './sql.js';
28
+
29
+ export const TIMELINE_DEFAULTS = Object.freeze({
30
+ captureQueries: true,
31
+ captureOutgoing: true,
32
+ captureLogs: true,
33
+ queryParams: true,
34
+ slowQueryMs: 100,
35
+ nPlusOneThreshold: 5,
36
+ loopBlockMs: 100
37
+ });
38
+
39
+ // Spans listed per request, by type.
40
+ const SPAN_CAPS = Object.freeze({ query: 500, http: 100, log: 200, measure: 100, exception: 10, block: 20 });
41
+ const MAX_STATEMENTS = 200;
42
+ // Executions per statement whose parameters are kept.
43
+ const PARAMS_PER_STATEMENT = 20;
44
+ const MAX_PARAMS = 50;
45
+ const BUDGET_BYTES = 512 * 1024;
46
+ const MAX_TEXT = 4096;
47
+ const MAX_ERROR = 500;
48
+ const MAX_LOG = 2000;
49
+ const MAX_NAME = 200;
50
+ const MAX_EXCEPTION_MESSAGE = 500;
51
+ // Distinct executions remembered per statement for duplicate detection.
52
+ const MAX_SEEN = 1000;
53
+
54
+ // Shared by every timeline: statements repeat across requests.
55
+ const sqlText = new SqlText();
56
+ const shapeFingerprints = new LruCache(500);
57
+
58
+ /**
59
+ * What one PeekInside instance records on its timelines.
60
+ */
61
+ export class TimelineSettings {
62
+ /**
63
+ * @param {Object} options - PeekInside options (see TIMELINE_DEFAULTS)
64
+ * @param {string} root - The project folder, where callers are looked for
65
+ */
66
+ constructor(options, root) {
67
+ this.captureQueries = options.captureQueries !== false;
68
+ this.captureOutgoing = options.captureOutgoing !== false;
69
+ this.captureLogs = options.captureLogs !== false;
70
+ this.queryParams = options.queryParams !== false;
71
+ this.slowQueryMs = Number.isFinite(options.slowQueryMs) && options.slowQueryMs >= 0 ? options.slowQueryMs : TIMELINE_DEFAULTS.slowQueryMs;
72
+ this.nPlusOneThreshold = Number.isInteger(options.nPlusOneThreshold) && options.nPlusOneThreshold >= 2
73
+ ? options.nPlusOneThreshold
74
+ : TIMELINE_DEFAULTS.nPlusOneThreshold;
75
+ // 0 turns block detection off (loopBlockMs: false).
76
+ this.loopBlockMs = options.loopBlockMs === false || options.loopBlockMs === 0
77
+ ? 0
78
+ : Number.isFinite(options.loopBlockMs) && options.loopBlockMs >= 50 ? options.loopBlockMs : TIMELINE_DEFAULTS.loopBlockMs;
79
+ this.caller = new Caller(root);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * One request's timeline.
85
+ */
86
+ export class Timeline {
87
+ /**
88
+ * The open timeline of the request the calling code runs for.
89
+ *
90
+ * @returns {Timeline|null}
91
+ */
92
+ static current() {
93
+ const context = RequestContext.current();
94
+ const timeline = context && context.timeline;
95
+ return timeline && !timeline.closed ? timeline : null;
96
+ }
97
+
98
+ /**
99
+ * The fingerprint a SQL statement gets, for code that needs it before running the statement
100
+ * (caller hints).
101
+ *
102
+ * @param {string} text
103
+ * @param {string} system
104
+ * @returns {string|null} null when the text can't be analyzed
105
+ */
106
+ static sqlFingerprint(text, system) {
107
+ try {
108
+ return sqlText.analyze(text, system).fingerprint;
109
+ } catch (error) {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @param {TimelineSettings} settings
116
+ * @param {number} [origin=performance.now()] - performance.now() at the instant the request's
117
+ * occurredAt was taken: span starts are offsets from it
118
+ */
119
+ constructor(settings, origin = performance.now()) {
120
+ this.settings = settings;
121
+ this.origin = origin;
122
+ this.closed = false;
123
+ this.spans = [];
124
+ this.listed = { query: 0, http: 0, log: 0, measure: 0, exception: 0, block: 0 };
125
+ this.dropped = 0;
126
+ this.statements = [];
127
+ this.byFingerprint = new Map();
128
+ // Statements past the cap: counted, not listed.
129
+ this.overflow = new Map();
130
+ this.stats = { queries: 0, queryTime: 0, slowQueries: 0, duplicates: 0, http: 0, httpTime: 0, logs: 0, loopBlock: 0 };
131
+ this._result = null;
132
+ if (settings.loopBlockMs > 0) LoopMonitor.track(this);
133
+ }
134
+
135
+ /**
136
+ * Whether statements need a caller hint: true when `fingerprint` hasn't run in this request yet.
137
+ *
138
+ * @param {string} fingerprint
139
+ * @returns {boolean}
140
+ */
141
+ needsCaller(fingerprint) {
142
+ return typeof fingerprint === 'string' && !this.byFingerprint.has(fingerprint) && this.statements.length < MAX_STATEMENTS;
143
+ }
144
+
145
+ /**
146
+ * Starts a query span.
147
+ *
148
+ * @param {Object} query
149
+ * @param {'postgresql'|'mysql'|'mongodb'|'sqlite'|'sqlserver'|'other'} query.system
150
+ * @param {'pg'|'mysql2'|'mongodb'|'prisma'} query.client
151
+ * @param {string} query.text - SQL as sent; for MongoDB and Prisma, the value-free shape
152
+ * @param {boolean} [query.sql=true] - Whether `text` is SQL (analyzed and masked) or a shape
153
+ * @param {Array|Object} [query.params] - The values sent, unmasked (they are masked here)
154
+ * @param {string} [query.operation] - The ORM call that ran it, such as "User.findMany"
155
+ * @param {() => (import('./caller.js').CallerInfo|null)} [query.caller] - Finds the caller when
156
+ * the stack can't (Prisma)
157
+ * @param {number} [query.startedAt] - performance.now() when the query started, for a query
158
+ * recorded once it is over; now by default
159
+ * @returns {SpanHandle|null} null when queries aren't recorded, or this one couldn't be (the
160
+ * app's query runs all the same)
161
+ */
162
+ query(query) {
163
+ try {
164
+ return this._query(query);
165
+ } catch (error) {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ _query({ system, client, text, sql = true, params, operation, caller, startedAt = performance.now() }) {
171
+ if (this.closed || !this.settings.captureQueries) return null;
172
+ const analysis = sql ? sqlText.analyze(text, system) : shapeAnalysis(text);
173
+ this.stats.queries++;
174
+
175
+ let statement = this.byFingerprint.get(analysis.fingerprint);
176
+ if (!statement && this.statements.length < MAX_STATEMENTS) {
177
+ statement = new Statement({
178
+ index: this.statements.length,
179
+ system,
180
+ client,
181
+ text: cut(analysis.maskedText, MAX_TEXT),
182
+ fingerprint: analysis.fingerprint,
183
+ caller: this._caller(analysis.fingerprint, caller)
184
+ });
185
+ this.statements.push(statement);
186
+ this.byFingerprint.set(analysis.fingerprint, statement);
187
+ }
188
+ const tracked = statement || this._overflow(analysis.fingerprint);
189
+ tracked.count++;
190
+ const execution = ExecutionHash.of(text, params);
191
+ if (tracked.seen.has(execution)) {
192
+ tracked.duplicates++;
193
+ this.stats.duplicates++;
194
+ } else if (tracked.seen.size < MAX_SEEN) {
195
+ tracked.seen.add(execution);
196
+ }
197
+
198
+ let span = null;
199
+ if (statement && this.listed.query < SPAN_CAPS.query) {
200
+ this.listed.query++;
201
+ span = { type: 'query', start: this._offset(startedAt), duration: null, statement: statement.index };
202
+ const shown = cut(analysis.maskedText, MAX_TEXT);
203
+ if (shown !== statement.text) span.text = shown;
204
+ if (this.settings.queryParams && params != null && statement.paramsKept < PARAMS_PER_STATEMENT) {
205
+ const masked = maskParams(params, analysis.sensitive);
206
+ if (masked !== undefined) {
207
+ statement.paramsKept++;
208
+ span.params = masked;
209
+ }
210
+ }
211
+ if (operation) span.operation = String(operation).slice(0, MAX_NAME);
212
+ this.spans.push(span);
213
+ } else {
214
+ this.dropped++;
215
+ }
216
+ return new SpanHandle(this, 'query', span, startedAt, tracked);
217
+ }
218
+
219
+ /**
220
+ * Starts an outgoing HTTP call span.
221
+ *
222
+ * @param {Object} call
223
+ * @param {'http'|'fetch'} call.client
224
+ * @param {string} call.method
225
+ * @param {string} call.url - Masked here (credentials, sensitive query values)
226
+ * @param {string} call.host
227
+ * @returns {SpanHandle|null} null when outgoing calls aren't recorded, or this one couldn't be
228
+ */
229
+ http(call) {
230
+ try {
231
+ return this._http(call);
232
+ } catch (error) {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ _http({ client, method, url, host }) {
238
+ if (this.closed || !this.settings.captureOutgoing) return null;
239
+ const startedAt = performance.now();
240
+ let span = null;
241
+ if (this.listed.http < SPAN_CAPS.http) {
242
+ span = {
243
+ type: 'http',
244
+ start: this._offset(startedAt),
245
+ duration: null,
246
+ client,
247
+ method: String(method || 'GET').toUpperCase().slice(0, 16),
248
+ url: Masker.url(url),
249
+ host: String(host || '').toLowerCase().slice(0, 255),
250
+ status: null
251
+ };
252
+ this.listed.http++;
253
+ this.spans.push(span);
254
+ } else {
255
+ this.dropped++;
256
+ }
257
+ this.stats.http++;
258
+ return new SpanHandle(this, 'http', span, startedAt, null);
259
+ }
260
+
261
+ /**
262
+ * Records a log line.
263
+ *
264
+ * @param {'debug'|'info'|'warn'|'error'} level
265
+ * @param {string} message - Formatted as it was written
266
+ * @param {'console'|'pino'|'winston'} [source='console']
267
+ * @returns {void}
268
+ */
269
+ log(level, message, source = 'console') {
270
+ if (this.closed || !this.settings.captureLogs) return;
271
+ try {
272
+ const span = { type: 'log', start: this._offset(performance.now()), level, message: cut(String(message), MAX_LOG), source };
273
+ this.stats.logs++;
274
+ if (this.listed.log < SPAN_CAPS.log) {
275
+ this.listed.log++;
276
+ this.spans.push(span);
277
+ } else {
278
+ this.dropped++;
279
+ }
280
+ } catch (error) {
281
+ // A message that can't be read as text isn't recorded.
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Records an exception recorded during the request.
287
+ *
288
+ * @param {{ id: string, summary: { name: string, message: string } }} record - An ExceptionRecord
289
+ * @returns {void}
290
+ */
291
+ exception(record) {
292
+ if (this.closed) return;
293
+ try {
294
+ if (this.listed.exception < SPAN_CAPS.exception) {
295
+ this.spans.push({
296
+ type: 'exception',
297
+ start: this._offset(performance.now()),
298
+ id: record.id,
299
+ name: record.summary.name,
300
+ message: cut(record.summary.message, MAX_EXCEPTION_MESSAGE)
301
+ });
302
+ this.listed.exception++;
303
+ } else {
304
+ this.dropped++;
305
+ }
306
+ } catch (error) {
307
+ // Not recorded on the timeline; the exception itself is.
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Records that the event loop was blocked while the request was in flight (see LoopMonitor):
313
+ * the request waited, whichever code blocked it. Blocks shorter than the settings' loopBlockMs
314
+ * are left out.
315
+ *
316
+ * @param {number} start - performance.now() when the block began
317
+ * @param {number} duration - ms
318
+ * @returns {void}
319
+ */
320
+ block(start, duration) {
321
+ if (this.closed || !this.settings.loopBlockMs) return;
322
+ // A block that began before the request counts from the request's start.
323
+ const from = Math.max(start, this.origin);
324
+ const length = start + duration - from;
325
+ if (length < this.settings.loopBlockMs) return;
326
+ if (length > this.stats.loopBlock) this.stats.loopBlock = length;
327
+ if (this.listed.block < SPAN_CAPS.block) {
328
+ this.listed.block++;
329
+ this.spans.push({ type: 'block', start: this._offset(from), duration: round(length) });
330
+ } else {
331
+ this.dropped++;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Starts a measure span (PeekInside#measure()).
337
+ *
338
+ * @param {string} name
339
+ * @returns {SpanHandle|null} null once the timeline is closed, or for a name that can't be read
340
+ * as text
341
+ */
342
+ measure(name) {
343
+ if (this.closed) return null;
344
+ const startedAt = performance.now();
345
+ let span = null;
346
+ try {
347
+ if (this.listed.measure < SPAN_CAPS.measure) {
348
+ span = { type: 'measure', start: this._offset(startedAt), duration: null, name: cut(String(name), MAX_NAME) };
349
+ this.listed.measure++;
350
+ this.spans.push(span);
351
+ } else {
352
+ this.dropped++;
353
+ }
354
+ } catch (error) {
355
+ return null;
356
+ }
357
+ return new SpanHandle(this, 'measure', span, startedAt, null);
358
+ }
359
+
360
+ /**
361
+ * Closes the timeline, for the request's record. Later spans are ignored; spans still open keep
362
+ * a null duration. Calling it again returns the same result.
363
+ *
364
+ * @returns {{ stats: Object, timeline: Object|undefined }} `timeline` only when something was
365
+ * recorded
366
+ */
367
+ close() {
368
+ if (this._result) return this._result;
369
+ // The request may have blocked the loop itself and finished before the monitor could tell.
370
+ const pending = this.settings.loopBlockMs ? LoopMonitor.pending() : null;
371
+ if (pending) this.block(pending.start, pending.duration);
372
+ LoopMonitor.untrack(this);
373
+ this.closed = true;
374
+ const threshold = this.settings.nPlusOneThreshold;
375
+ let nPlusOne = 0;
376
+ for (const statement of this.statements) if (statement.count >= threshold) nPlusOne++;
377
+ for (const statement of this.overflow.values()) if (statement.count >= threshold) nPlusOne++;
378
+
379
+ const stats = {
380
+ queries: this.stats.queries,
381
+ queryTime: round(this.stats.queryTime),
382
+ slowQueries: this.stats.slowQueries,
383
+ nPlusOne,
384
+ duplicates: this.stats.duplicates,
385
+ http: this.stats.http,
386
+ httpTime: round(this.stats.httpTime),
387
+ logs: this.stats.logs,
388
+ loopBlock: round(this.stats.loopBlock)
389
+ };
390
+
391
+ // By start, then in the order they were created (a stable sort): spans recorded once they were
392
+ // over (a Prisma operation) start before spans created in the meantime.
393
+ const spans = this.spans.filter((span) => !span.cancelled).sort((a, b) => a.start - b.start);
394
+ let timeline;
395
+ if (spans.length > 0 || this.dropped > 0) {
396
+ timeline = fitBudget({
397
+ version: 1,
398
+ spans,
399
+ statements: this.statements.map((statement) => statement.toJSON(threshold)),
400
+ dropped: this.dropped,
401
+ slowQueryMs: this.settings.slowQueryMs,
402
+ nPlusOneThreshold: threshold
403
+ });
404
+ }
405
+ this._result = { stats, timeline };
406
+ return this._result;
407
+ }
408
+
409
+ // Milliseconds since the request started, 2 decimals.
410
+ _offset(at) {
411
+ return round(at - this.origin);
412
+ }
413
+
414
+ _caller(fingerprint, find) {
415
+ try {
416
+ if (typeof find === 'function') return find();
417
+ // A query an ORM call runs: the line that made the call, when the ORM captured it.
418
+ const found = Operation.caller();
419
+ if (found) return found;
420
+ const hint = Caller.hinted(fingerprint);
421
+ return hint ? hint.caller : this.settings.caller.capture();
422
+ } catch (error) {
423
+ return null;
424
+ }
425
+ }
426
+
427
+ _overflow(fingerprint) {
428
+ let entry = this.overflow.get(fingerprint);
429
+ if (!entry) {
430
+ entry = { count: 0, duplicates: 0, time: 0, maxTime: 0, slow: 0, seen: new Set() };
431
+ this.overflow.set(fingerprint, entry);
432
+ }
433
+ return entry;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * A span in progress. Ending it twice, or after the timeline closed, does nothing.
439
+ */
440
+ export class SpanHandle {
441
+ /**
442
+ * @param {Timeline} timeline
443
+ * @param {'query'|'http'|'measure'} type
444
+ * @param {Object|null} span - The listed span, or null when a cap left it out
445
+ * @param {number} startedAt - performance.now() at the start
446
+ * @param {Object|null} statement - For queries: the statement it counts towards
447
+ */
448
+ constructor(timeline, type, span, startedAt, statement) {
449
+ this.timeline = timeline;
450
+ this.type = type;
451
+ this.span = span;
452
+ this.startedAt = startedAt;
453
+ this.statement = statement;
454
+ this.done = false;
455
+ }
456
+
457
+ /**
458
+ * Ends the span.
459
+ *
460
+ * @param {Object} [result]
461
+ * @param {number|null} [result.rows] - Queries: rows returned or affected
462
+ * @param {number|null} [result.status] - HTTP: the response status
463
+ * @param {*} [result.error] - What made it fail (an Error, or a message)
464
+ * @returns {void}
465
+ */
466
+ end(result) {
467
+ if (this.done) return;
468
+ this.done = true;
469
+ try {
470
+ this._end(result || {});
471
+ } catch (error) {
472
+ // Called from the driver's own completion path: whatever happens here stays here.
473
+ }
474
+ }
475
+
476
+ _end({ rows, status, error }) {
477
+ const timeline = this.timeline;
478
+ if (timeline.closed) return;
479
+ const duration = performance.now() - this.startedAt;
480
+ const span = this.span;
481
+ const failure = error === undefined || error === null ? null : errorText(error);
482
+
483
+ if (this.type === 'query') {
484
+ const statement = this.statement;
485
+ timeline.stats.queryTime += duration;
486
+ statement.time += duration;
487
+ statement.maxTime = Math.max(statement.maxTime, duration);
488
+ if (duration >= timeline.settings.slowQueryMs) {
489
+ statement.slow++;
490
+ timeline.stats.slowQueries++;
491
+ }
492
+ if (span && Number.isInteger(rows) && rows >= 0) span.rows = rows;
493
+ } else if (this.type === 'http') {
494
+ timeline.stats.httpTime += duration;
495
+ if (span && Number.isInteger(status)) span.status = status;
496
+ }
497
+ if (span) {
498
+ span.duration = round(duration);
499
+ if (failure) span.error = failure;
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Takes the span back: the operation never ran (its call threw synchronously).
505
+ *
506
+ * @returns {void}
507
+ */
508
+ cancel() {
509
+ if (this.done) return;
510
+ this.done = true;
511
+ const timeline = this.timeline;
512
+ if (timeline.closed) return;
513
+ if (this.type === 'query') {
514
+ timeline.stats.queries--;
515
+ this.statement.count--;
516
+ } else if (this.type === 'http') {
517
+ timeline.stats.http--;
518
+ }
519
+ if (this.span) this.span.cancelled = true;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * One distinct statement of a request.
525
+ */
526
+ class Statement {
527
+ /**
528
+ * @param {Object} fields - index, system, client, text, fingerprint, caller
529
+ */
530
+ constructor({ index, system, client, text, fingerprint, caller }) {
531
+ this.index = index;
532
+ this.system = system;
533
+ this.client = client;
534
+ this.text = text;
535
+ this.fingerprint = fingerprint;
536
+ this.caller = caller || null;
537
+ this.count = 0;
538
+ this.time = 0;
539
+ this.maxTime = 0;
540
+ this.slow = 0;
541
+ this.duplicates = 0;
542
+ this.paramsKept = 0;
543
+ this.seen = new Set();
544
+ }
545
+
546
+ /**
547
+ * @param {number} threshold - nPlusOneThreshold
548
+ * @returns {Object} The statement as the contract lists it
549
+ */
550
+ toJSON(threshold) {
551
+ return {
552
+ system: this.system,
553
+ client: this.client,
554
+ text: this.text,
555
+ fingerprint: this.fingerprint,
556
+ count: this.count,
557
+ time: round(this.time),
558
+ maxTime: round(this.maxTime),
559
+ slow: this.slow,
560
+ nPlusOne: this.count >= threshold,
561
+ duplicates: this.duplicates,
562
+ caller: this.caller
563
+ };
564
+ }
565
+ }
566
+
567
+ // Shapes are value-free already: the fingerprint is the hash of the text itself.
568
+ function shapeAnalysis(text) {
569
+ const shape = String(text);
570
+ let fingerprint = shapeFingerprints.get(shape);
571
+ if (!fingerprint) {
572
+ fingerprint = createHash('sha256').update(shape).digest('hex').slice(0, 16);
573
+ shapeFingerprints.set(shape, fingerprint);
574
+ }
575
+ return { fingerprint, maskedText: shape, sensitive: new Set() };
576
+ }
577
+
578
+ // A query span's parameters, masked; undefined when they can't be read (the span goes without).
579
+ function maskParams(params, sensitive) {
580
+ try {
581
+ if (Array.isArray(params)) return Masker.sqlParams(params, sensitive, MAX_PARAMS);
582
+ return typeof params === 'object' ? Masker.fields(params, MAX_PARAMS) : null;
583
+ } catch (error) {
584
+ return undefined;
585
+ }
586
+ }
587
+
588
+ // Keeps the serialized timeline within the budget: first the parameters of the newest query spans
589
+ // go, then the newest spans themselves (counted in `dropped`), and as a last resort the statements'
590
+ // texts are shortened.
591
+ function fitBudget(timeline) {
592
+ let size = Buffer.byteLength(JSON.stringify(timeline));
593
+ if (size <= BUDGET_BYTES) return timeline;
594
+
595
+ const spans = timeline.spans.map((span) => ({ ...span }));
596
+ const lengths = spans.map((span) => Buffer.byteLength(JSON.stringify(span)) + 1);
597
+ for (let i = spans.length - 1; i >= 0 && size > BUDGET_BYTES; i--) {
598
+ if (spans[i].type !== 'query' || spans[i].params === undefined) continue;
599
+ delete spans[i].params;
600
+ const length = Buffer.byteLength(JSON.stringify(spans[i])) + 1;
601
+ size -= lengths[i] - length;
602
+ lengths[i] = length;
603
+ }
604
+ let dropped = timeline.dropped;
605
+ while (size > BUDGET_BYTES && spans.length > 0) {
606
+ size -= lengths.pop();
607
+ spans.pop();
608
+ dropped++;
609
+ }
610
+ let result = { ...timeline, spans, dropped };
611
+ for (const max of [1024, 256, 64]) {
612
+ if (Buffer.byteLength(JSON.stringify(result)) <= BUDGET_BYTES) break;
613
+ result = { ...result, statements: result.statements.map((statement) => ({ ...statement, text: cut(statement.text, max) })) };
614
+ }
615
+ return result;
616
+ }
617
+
618
+ // "<code>: <message>" or "<message>", at most 500 characters.
619
+ function errorText(error) {
620
+ if (typeof error === 'string') return cut(error, MAX_ERROR);
621
+ let message;
622
+ let code;
623
+ try {
624
+ message = typeof error.message === 'string' ? error.message : String(error);
625
+ code = error.code;
626
+ } catch (failure) {
627
+ message = 'Error';
628
+ }
629
+ // Some libraries (Prisma) format messages over several lines with blank lines between.
630
+ const tidy = String(message).trim().replace(/\n\s*\n+/g, '\n');
631
+ const text = (typeof code === 'string' || typeof code === 'number') && code !== '' ? `${code}: ${tidy}` : tidy;
632
+ return cut(text, MAX_ERROR);
633
+ }
634
+
635
+ function cut(text, max) {
636
+ if (typeof text !== 'string') return text;
637
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
638
+ }
639
+
640
+ function round(value) {
641
+ return Math.round(value * 100) / 100;
642
+ }