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.
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 +561 -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,162 @@
1
+ /**
2
+ * Watches the event loop with a light timer that measures how late it fires. A late timer means
3
+ * the loop was blocked by synchronous work (a large JSON.parse, a slow regular expression, a sync
4
+ * file read, a CPU-heavy loop), and every request in flight waited for it. Blocks long enough are
5
+ * recorded on the timelines of those requests (Timeline#block); every delay feeds the process
6
+ * samples (see RuntimeSampler).
7
+ *
8
+ * One monitor serves the whole process. Its timer is unref'd, never keeps the process alive, and
9
+ * runs outside any request; the work per tick is a subtraction and a write into a fixed buffer.
10
+ */
11
+
12
+ import { performance } from 'perf_hooks';
13
+ import { RequestContext } from '../exceptions/request-context.js';
14
+
15
+ // How often the timer fires: a block is measured within TICK_MS.
16
+ export const TICK_MS = 20;
17
+ // Delays kept for the samples: two minutes of ticks.
18
+ const MAX_DELAYS = Math.ceil(120_000 / TICK_MS);
19
+ // Shorter delays are scheduling noise, never a block worth showing.
20
+ const MIN_BLOCK_MS = 50;
21
+
22
+ /**
23
+ * The process's event-loop monitor.
24
+ */
25
+ export class LoopMonitor {
26
+ /**
27
+ * Starts the shared monitor (or counts one more user of it).
28
+ *
29
+ * @returns {LoopMonitor}
30
+ */
31
+ static acquire() {
32
+ if (!LoopMonitor.shared) LoopMonitor.shared = new LoopMonitor();
33
+ LoopMonitor.shared.users++;
34
+ LoopMonitor.shared.start();
35
+ return LoopMonitor.shared;
36
+ }
37
+
38
+ /**
39
+ * One user is done; the monitor stops with the last one.
40
+ *
41
+ * @returns {void}
42
+ */
43
+ static release() {
44
+ const monitor = LoopMonitor.shared;
45
+ if (!monitor) return;
46
+ monitor.users = Math.max(0, monitor.users - 1);
47
+ if (monitor.users === 0) monitor.stop();
48
+ }
49
+
50
+ /**
51
+ * Follows a timeline until it is untracked: blocks that happen meanwhile are recorded on it.
52
+ * Does nothing while no monitor runs.
53
+ *
54
+ * @param {{ block: (start: number, duration: number) => void }} timeline
55
+ * @returns {void}
56
+ */
57
+ static track(timeline) {
58
+ if (LoopMonitor.shared && LoopMonitor.shared.timer) LoopMonitor.shared.timelines.add(timeline);
59
+ }
60
+
61
+ /**
62
+ * @param {Object} timeline
63
+ * @returns {void}
64
+ */
65
+ static untrack(timeline) {
66
+ if (LoopMonitor.shared) LoopMonitor.shared.timelines.delete(timeline);
67
+ }
68
+
69
+ /**
70
+ * The block in progress, if the timer is late right now: a request that blocked the loop itself
71
+ * often finishes before the timer can fire, and asks when its timeline closes.
72
+ *
73
+ * @param {number} [now=performance.now()]
74
+ * @returns {{ start: number, duration: number }|null}
75
+ */
76
+ static pending(now = performance.now()) {
77
+ const monitor = LoopMonitor.shared;
78
+ if (!monitor || !monitor.timer) return null;
79
+ const due = monitor.last + TICK_MS;
80
+ return now - due >= MIN_BLOCK_MS ? { start: due, duration: now - due } : null;
81
+ }
82
+
83
+ constructor() {
84
+ this.users = 0;
85
+ this.timer = null;
86
+ this.last = 0;
87
+ this.timelines = new Set();
88
+ // Ring buffer of (performance.now(), delay ms) per tick.
89
+ this.times = new Float64Array(MAX_DELAYS);
90
+ this.delays = new Float64Array(MAX_DELAYS);
91
+ this.next = 0;
92
+ this.size = 0;
93
+ }
94
+
95
+ /**
96
+ * @returns {void}
97
+ */
98
+ start() {
99
+ if (this.timer) return;
100
+ this.last = performance.now();
101
+ this.timer = RequestContext.outside(() => setInterval(() => this.tick(), TICK_MS));
102
+ if (typeof this.timer.unref === 'function') this.timer.unref();
103
+ }
104
+
105
+ /**
106
+ * @returns {void}
107
+ */
108
+ stop() {
109
+ clearInterval(this.timer);
110
+ this.timer = null;
111
+ this.timelines.clear();
112
+ }
113
+
114
+ /**
115
+ * One tick of the timer: how late it fired is how long the loop was blocked.
116
+ *
117
+ * @param {number} [now=performance.now()]
118
+ * @returns {void}
119
+ */
120
+ tick(now = performance.now()) {
121
+ const delay = Math.max(0, now - this.last - TICK_MS);
122
+ this.last = now;
123
+ this.times[this.next] = now;
124
+ this.delays[this.next] = delay;
125
+ this.next = (this.next + 1) % MAX_DELAYS;
126
+ this.size = Math.min(MAX_DELAYS, this.size + 1);
127
+ if (delay < MIN_BLOCK_MS) return;
128
+ // The loop was blocked from when the tick was due until now.
129
+ for (const timeline of this.timelines) {
130
+ try {
131
+ timeline.block(now - delay, delay);
132
+ } catch (error) {
133
+ // One timeline's failure never affects the others.
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The event-loop delay since a moment: its median, 99th percentile and maximum, in ms.
140
+ *
141
+ * @param {number} since - performance.now() at the start of the window
142
+ * @returns {{ p50: number, p99: number, max: number }|null} null without ticks in the window
143
+ */
144
+ delaySince(since) {
145
+ const window = [];
146
+ for (let i = 0; i < this.size; i++) {
147
+ const index = (this.next - 1 - i + MAX_DELAYS) % MAX_DELAYS;
148
+ if (this.times[index] < since) break;
149
+ window.push(this.delays[index]);
150
+ }
151
+ if (!window.length) return null;
152
+ window.sort((a, b) => a - b);
153
+ const at = (fraction) => window[Math.min(window.length - 1, Math.floor(fraction * window.length))];
154
+ return { p50: round(at(0.5)), p99: round(at(0.99)), max: round(window[window.length - 1]) };
155
+ }
156
+ }
157
+
158
+ LoopMonitor.shared = null;
159
+
160
+ function round(value) {
161
+ return Math.round(value * 10) / 10;
162
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Samples the process now and then: memory (to spot leaks, as growth over hours), CPU and the
3
+ * event-loop delay. Samples are numbers only, and go to the dashboard with the other records:
4
+ * every 10 seconds to the local dashboard, every minute to PeekInside Cloud.
5
+ */
6
+
7
+ import { performance } from 'perf_hooks';
8
+ import { RequestContext } from '../exceptions/request-context.js';
9
+
10
+ /**
11
+ * @typedef {Object} RuntimeSample
12
+ * @property {number} at - When it was taken (ms since the epoch)
13
+ * @property {number} rss - Bytes of memory the process holds
14
+ * @property {number} heapUsed - Bytes of JavaScript objects
15
+ * @property {number} heapTotal - Bytes of the JavaScript heap
16
+ * @property {number} external - Bytes of memory outside the heap (buffers)
17
+ * @property {number} cpu - Percent of one core used since the last sample
18
+ * @property {number|null} loopP50 - Event-loop delay since the last sample, ms
19
+ * @property {number|null} loopP99
20
+ * @property {number|null} loopMax
21
+ */
22
+
23
+ /**
24
+ * Takes a sample every `intervalMs` and hands it over.
25
+ */
26
+ export class RuntimeSampler {
27
+ /**
28
+ * @param {Object} options
29
+ * @param {number} options.intervalMs
30
+ * @param {(sample: RuntimeSample) => void} options.onSample
31
+ * @param {import('./loop-monitor.js').LoopMonitor} options.monitor - Measures the delay
32
+ */
33
+ constructor({ intervalMs, onSample, monitor }) {
34
+ this.intervalMs = intervalMs;
35
+ this.onSample = onSample;
36
+ this.monitor = monitor;
37
+ this.timer = null;
38
+ this.lastAt = performance.now();
39
+ this.lastCpu = process.cpuUsage();
40
+ }
41
+
42
+ /**
43
+ * @returns {this}
44
+ */
45
+ start() {
46
+ if (this.timer) return this;
47
+ this.lastAt = performance.now();
48
+ this.lastCpu = process.cpuUsage();
49
+ this.timer = RequestContext.outside(() => setInterval(() => this.take(), this.intervalMs));
50
+ if (typeof this.timer.unref === 'function') this.timer.unref();
51
+ return this;
52
+ }
53
+
54
+ /**
55
+ * @returns {void}
56
+ */
57
+ stop() {
58
+ clearInterval(this.timer);
59
+ this.timer = null;
60
+ }
61
+
62
+ /**
63
+ * Takes a sample now and hands it over. Never throws.
64
+ *
65
+ * @returns {RuntimeSample|null}
66
+ */
67
+ take() {
68
+ try {
69
+ const now = performance.now();
70
+ const elapsed = Math.max(1, now - this.lastAt);
71
+ const cpu = process.cpuUsage(this.lastCpu);
72
+ const delay = this.monitor ? this.monitor.delaySince(this.lastAt) : null;
73
+ this.lastAt = now;
74
+ this.lastCpu = process.cpuUsage();
75
+ const memory = process.memoryUsage();
76
+ const sample = {
77
+ at: Date.now(),
78
+ rss: memory.rss,
79
+ heapUsed: memory.heapUsed,
80
+ heapTotal: memory.heapTotal,
81
+ external: memory.external,
82
+ cpu: Math.round(((cpu.user + cpu.system) / 1000 / elapsed) * 1000) / 10,
83
+ loopP50: delay ? delay.p50 : null,
84
+ loopP99: delay ? delay.p99 : null,
85
+ loopMax: delay ? delay.max : null
86
+ };
87
+ this.onSample(sample);
88
+ return sample;
89
+ } catch (error) {
90
+ return null;
91
+ }
92
+ }
93
+ }
package/src/spool.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Spool folders: records written to disk by a process that may exit at any moment (it is
3
+ * crashing), for someone else to deliver later.
4
+ *
5
+ * <data dir>/spool/<project id>/<uuid>.json Local hub batches ({ project, instance,
6
+ * requests, exceptions }), which the hub
7
+ * ingests at startup and every second
8
+ * <data dir>/spool/cloud/<project id>/<uuid>.json Sealed PeekInside Cloud batches, which the
9
+ * SDK resends when the app starts again
10
+ *
11
+ * Writing is synchronous and atomic (a temporary file, then a rename), so a reader never sees half
12
+ * a file. Folders are private to the user (0700, files 0600), like the rest of the data folder.
13
+ */
14
+
15
+ import { randomUUID } from 'crypto';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+
19
+ // The "project" folder that holds cloud batches; local project ids always contain a dash.
20
+ export const CLOUD_FOLDER = 'cloud';
21
+ // A temporary file this old was left by a writer that died mid-write.
22
+ const STALE_TEMP_MS = 60_000;
23
+
24
+ /**
25
+ * One spool folder.
26
+ */
27
+ export class Spool {
28
+ /**
29
+ * The folder of a local hub project.
30
+ *
31
+ * @param {string} dataDir
32
+ * @param {string} projectId
33
+ * @returns {Spool}
34
+ */
35
+ static local(dataDir, projectId) {
36
+ return new Spool(path.join(Spool.root(dataDir), projectId));
37
+ }
38
+
39
+ /**
40
+ * The folder of a PeekInside Cloud project.
41
+ *
42
+ * @param {string} dataDir
43
+ * @param {string} projectId
44
+ * @returns {Spool}
45
+ */
46
+ static cloud(dataDir, projectId) {
47
+ return new Spool(path.join(Spool.root(dataDir), CLOUD_FOLDER, projectId));
48
+ }
49
+
50
+ /**
51
+ * @param {string} dataDir
52
+ * @returns {string} The folder that holds every spool folder
53
+ */
54
+ static root(dataDir) {
55
+ return path.join(dataDir, 'spool');
56
+ }
57
+
58
+ /**
59
+ * @param {string} dir
60
+ */
61
+ constructor(dir) {
62
+ this.dir = dir;
63
+ }
64
+
65
+ /**
66
+ * Writes one file, synchronously.
67
+ *
68
+ * @param {*} content - Saved as JSON
69
+ * @returns {string} The file's name
70
+ * @throws {Error} When the folder can't be written (callers decide whether that matters)
71
+ */
72
+ write(content) {
73
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
74
+ const name = `${randomUUID()}.json`;
75
+ const file = path.join(this.dir, name);
76
+ const temp = `${file}.${process.pid}.tmp`;
77
+ fs.writeFileSync(temp, JSON.stringify(content), { mode: 0o600 });
78
+ fs.renameSync(temp, file);
79
+ return name;
80
+ }
81
+
82
+ /**
83
+ * The files waiting, oldest first. Files older than `maxAgeMs` are deleted instead, as are
84
+ * temporary files left by writers that died.
85
+ *
86
+ * @param {Object} [options]
87
+ * @param {number} [options.maxAgeMs=Infinity]
88
+ * @returns {string[]} File names
89
+ */
90
+ list({ maxAgeMs = Infinity } = {}) {
91
+ let names;
92
+ try {
93
+ names = fs.readdirSync(this.dir);
94
+ } catch (error) {
95
+ return [];
96
+ }
97
+
98
+ const now = Date.now();
99
+ const files = [];
100
+ for (const name of names) {
101
+ const file = path.join(this.dir, name);
102
+ let stat;
103
+ try {
104
+ stat = fs.statSync(file);
105
+ } catch (error) {
106
+ continue;
107
+ }
108
+ if (!stat.isFile()) continue;
109
+
110
+ if (name.endsWith('.tmp')) {
111
+ if (now - stat.mtimeMs > STALE_TEMP_MS) fs.rmSync(file, { force: true });
112
+ continue;
113
+ }
114
+ const claimed = /^(.+\.json)\.(\d+)\.sending$/.exec(name);
115
+ if (claimed) {
116
+ // A process that died while sending: the file is up for grabs again.
117
+ if (!isRunning(Number(claimed[2])) && this._rename(name, claimed[1])) files.push({ name: claimed[1], at: stat.mtimeMs });
118
+ continue;
119
+ }
120
+ if (!name.endsWith('.json')) continue;
121
+ if (now - stat.mtimeMs > maxAgeMs) {
122
+ fs.rmSync(file, { force: true });
123
+ continue;
124
+ }
125
+ files.push({ name, at: stat.mtimeMs });
126
+ }
127
+ return files.sort((a, b) => a.at - b.at).map((entry) => entry.name);
128
+ }
129
+
130
+ /**
131
+ * Reads a file.
132
+ *
133
+ * @param {string} name
134
+ * @returns {*} The parsed content, or undefined when it is missing or not JSON
135
+ */
136
+ read(name) {
137
+ try {
138
+ return JSON.parse(fs.readFileSync(path.join(this.dir, name), 'utf8'));
139
+ } catch (error) {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Deletes a file.
146
+ *
147
+ * @param {string} name
148
+ * @returns {void}
149
+ */
150
+ remove(name) {
151
+ fs.rmSync(path.join(this.dir, name), { force: true });
152
+ }
153
+
154
+ /**
155
+ * Claims a file for this process, so that processes starting together (cluster workers) don't
156
+ * send it twice: the rename succeeds for one of them only.
157
+ *
158
+ * @param {string} name - From list()
159
+ * @returns {string|null} The claimed file's name, or null when another process has it
160
+ */
161
+ claim(name) {
162
+ const claimed = `${name}.${process.pid}.sending`;
163
+ return this._rename(name, claimed) ? claimed : null;
164
+ }
165
+
166
+ /**
167
+ * Gives a claimed file back, to be sent another time.
168
+ *
169
+ * @param {string} claimed - From claim()
170
+ * @returns {void}
171
+ */
172
+ release(claimed) {
173
+ this._rename(claimed, claimed.replace(/\.\d+\.sending$/, ''));
174
+ }
175
+
176
+ _rename(from, to) {
177
+ try {
178
+ fs.renameSync(path.join(this.dir, from), path.join(this.dir, to));
179
+ return true;
180
+ } catch (error) {
181
+ return false;
182
+ }
183
+ }
184
+ }
185
+
186
+ // A process that exists but belongs to another user (EPERM) is running too.
187
+ function isRunning(pid) {
188
+ if (pid === process.pid) return true;
189
+ try {
190
+ process.kill(pid, 0);
191
+ return true;
192
+ } catch (error) {
193
+ return error.code === 'EPERM';
194
+ }
195
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Where the app ran a query: the first frame of the app's own code on the stack.
3
+ *
4
+ * Read once per statement and request, at its first execution, so an N+1 loop of a thousand
5
+ * queries reads the stack once. The stack is read with a higher Error.stackTraceLimit for that
6
+ * moment only: drivers sit several frames deep between the app and the point where they are
7
+ * hooked.
8
+ *
9
+ * Some calls reach the hooked method after the app's frame is gone: a pool runs the query from a
10
+ * callback on a later tick. For those, the integration reads the caller where the app called the
11
+ * pool and hands it down as a hint (AsyncLocalStorage), checked against the statement it was taken
12
+ * for.
13
+ */
14
+
15
+ import { AsyncLocalStorage } from 'async_hooks';
16
+ import { fileURLToPath } from 'url';
17
+ import { FrameClassifier } from '../exceptions/frame-classifier.js';
18
+ import { StackParser } from '../exceptions/stack-parser.js';
19
+
20
+ const STACK_LIMIT = 25;
21
+ // PeekInside's own files are never the caller, even when they are inside the project (PeekInside's
22
+ // own tests, a checkout used through npm link).
23
+ const SDK_DIR = fileURLToPath(new URL('../', import.meta.url));
24
+ const hints = new AsyncLocalStorage();
25
+
26
+ /**
27
+ * @typedef {Object} CallerInfo
28
+ * @property {string} file - Absolute path
29
+ * @property {string|null} relativeFile - Relative to the project folder, forward slashes
30
+ * @property {number|null} line
31
+ * @property {number|null} column
32
+ * @property {string|null} function
33
+ */
34
+
35
+ /**
36
+ * Finds the app's frame for statements.
37
+ */
38
+ export class Caller {
39
+ /**
40
+ * @param {string} root - The project folder
41
+ */
42
+ constructor(root) {
43
+ this.classifier = new FrameClassifier(root);
44
+ }
45
+
46
+ /**
47
+ * The app's frame on the current stack.
48
+ *
49
+ * @returns {CallerInfo|null}
50
+ */
51
+ capture() {
52
+ const saved = Error.stackTraceLimit;
53
+ const holder = {};
54
+ try {
55
+ Error.stackTraceLimit = STACK_LIMIT;
56
+ Error.captureStackTrace(holder, Caller.prototype.capture);
57
+ } catch (error) {
58
+ return null;
59
+ } finally {
60
+ Error.stackTraceLimit = saved;
61
+ }
62
+ try {
63
+ return this.fromStack(holder.stack);
64
+ } catch (error) {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The app's frame in a stack captured elsewhere (by Prisma, for one).
71
+ *
72
+ * @param {string} stack
73
+ * @returns {CallerInfo|null}
74
+ */
75
+ fromStack(stack) {
76
+ if (typeof stack !== 'string') return null;
77
+ for (const parsed of StackParser.parse(stack, { limit: STACK_LIMIT })) {
78
+ if (!parsed.file || parsed.file.startsWith(SDK_DIR)) continue;
79
+ const frame = this.classifier.classify(parsed);
80
+ if (frame.inApp) return Caller.fromFrame(frame);
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * A caller from a file position (Prisma's own call site format).
87
+ *
88
+ * @param {{ fileName: string, lineNumber?: number, columnNumber?: number }} location
89
+ * @returns {CallerInfo|null}
90
+ */
91
+ fromLocation(location) {
92
+ if (!location || typeof location.fileName !== 'string') return null;
93
+ const parsed = StackParser.parseLocation(`${location.fileName}:${location.lineNumber ?? 0}:${location.columnNumber ?? 0}`);
94
+ if (!parsed.file || parsed.file.startsWith(SDK_DIR)) return null;
95
+ const frame = this.classifier.classify({ function: null, ...parsed, async: false });
96
+ return frame.inApp ? Caller.fromFrame(frame) : null;
97
+ }
98
+
99
+ /**
100
+ * @param {import('../exceptions/frame-classifier.js').Frame} frame
101
+ * @returns {CallerInfo}
102
+ */
103
+ static fromFrame(frame) {
104
+ return { file: frame.file, relativeFile: frame.relativeFile, line: frame.line, column: frame.column, function: frame.function };
105
+ }
106
+
107
+ /**
108
+ * Runs `fn` with a caller hint for the statement `key`: code that runs the statement later (from
109
+ * a pool's callback) finds it with Caller.hinted().
110
+ *
111
+ * @param {{ key: string, caller: CallerInfo|null }} hint
112
+ * @param {Function} fn
113
+ * @returns {*} What fn returns
114
+ */
115
+ static withHint(hint, fn) {
116
+ return hints.run(hint, fn);
117
+ }
118
+
119
+ /**
120
+ * The hint for statement `key`, if one was given.
121
+ *
122
+ * @param {string} key
123
+ * @returns {{ caller: CallerInfo|null }|null}
124
+ */
125
+ static hinted(key) {
126
+ const hint = hints.getStore();
127
+ return hint && hint.key === key ? hint : null;
128
+ }
129
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Recognizes repeated executions: the same statement with the same parameters, run again in the
3
+ * same request (the result was already there, the app asked again).
4
+ *
5
+ * The parameters are compared through a hash of the unmasked values, so two executions that differ
6
+ * only in a masked value still differ, and the values themselves are never kept.
7
+ */
8
+
9
+ import { createHash } from 'crypto';
10
+ import { types } from 'util';
11
+
12
+ const MAX_DEPTH = 10;
13
+ // Values larger than this (an insertMany of thousands of documents) aren't compared: hashing them
14
+ // would cost more than the duplicate it might reveal is worth.
15
+ const MAX_NODES = 2000;
16
+ const TOO_LARGE = new Error('too large to compare');
17
+
18
+ /**
19
+ * Hashes executions.
20
+ */
21
+ export class ExecutionHash {
22
+ /**
23
+ * @param {string} text - The statement as sent
24
+ * @param {*} params - Its parameters, as the app passed them
25
+ * @returns {string} A short hash: equal for equal text and parameters
26
+ */
27
+ static of(text, params) {
28
+ const hash = createHash('sha1');
29
+ hash.update(String(text));
30
+ hash.update('\u0000');
31
+ try {
32
+ feed(hash, params, 0, [], { nodes: 0 });
33
+ } catch (error) {
34
+ // Too large, or a value that can't be read (a proxy, a throwing getter): this execution is
35
+ // its own.
36
+ hash.update(`\u0001${Math.random()}`);
37
+ }
38
+ return hash.digest('base64').slice(0, 16);
39
+ }
40
+ }
41
+
42
+ // Writes a value into the hash with type tags, so 1 and "1" differ, and Buffers go in as bytes.
43
+ function feed(hash, value, depth, ancestors, budget) {
44
+ if (++budget.nodes > MAX_NODES) throw TOO_LARGE;
45
+ if (value === null || value === undefined) {
46
+ hash.update(value === null ? 'n;' : 'u;');
47
+ return;
48
+ }
49
+ switch (typeof value) {
50
+ case 'string':
51
+ hash.update(`s${value.length}:`);
52
+ hash.update(value);
53
+ return;
54
+ case 'number':
55
+ case 'boolean':
56
+ case 'bigint':
57
+ hash.update(`${typeof value}:${String(value)};`);
58
+ return;
59
+ case 'function':
60
+ case 'symbol':
61
+ hash.update(`${typeof value};`);
62
+ return;
63
+ default:
64
+ break;
65
+ }
66
+ if (ArrayBuffer.isView(value)) {
67
+ hash.update(`b${value.byteLength}:`);
68
+ hash.update(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
69
+ return;
70
+ }
71
+ if (types.isAnyArrayBuffer(value)) {
72
+ hash.update(`b${value.byteLength}:`);
73
+ hash.update(new Uint8Array(value));
74
+ return;
75
+ }
76
+ if (types.isDate(value)) {
77
+ hash.update(`d${Date.prototype.getTime.call(value)};`);
78
+ return;
79
+ }
80
+ if (depth >= MAX_DEPTH || ancestors.includes(value)) {
81
+ hash.update('…;');
82
+ return;
83
+ }
84
+ ancestors.push(value);
85
+ try {
86
+ if (Array.isArray(value)) {
87
+ hash.update(`a${value.length}[`);
88
+ for (const item of value) feed(hash, item, depth + 1, ancestors, budget);
89
+ hash.update(']');
90
+ return;
91
+ }
92
+ // Driver values (ObjectId, Decimal) compare by what they serialize to.
93
+ if (typeof value.toJSON === 'function' && Object.getPrototypeOf(value) !== Object.prototype) {
94
+ const json = value.toJSON();
95
+ if (json !== value) {
96
+ hash.update('j');
97
+ feed(hash, json, depth + 1, ancestors, budget);
98
+ return;
99
+ }
100
+ }
101
+ hash.update('o{');
102
+ for (const key of Object.keys(value)) {
103
+ hash.update(`${key.length}:${key}=`);
104
+ feed(hash, value[key], depth + 1, ancestors, budget);
105
+ }
106
+ hash.update('}');
107
+ } finally {
108
+ ancestors.pop();
109
+ }
110
+ }