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,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
|
+
}
|