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