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,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeekInside - Real-time HTTP request inspector for Node.js applications
|
|
3
|
+
*
|
|
4
|
+
* Records every request and response your app handles. During development they go to the free
|
|
5
|
+
* local dashboard, which is shared by all your projects and keeps its data on this machine.
|
|
6
|
+
* With an API key they are encrypted and sent to PeekInside Cloud instead.
|
|
7
|
+
* Works with Express, Koa, Fastify and the native http module.
|
|
8
|
+
*
|
|
9
|
+
* Exceptions are recorded too: errors that reach the framework's error handling, crashes
|
|
10
|
+
* (uncaught exceptions and unhandled rejections), and errors the app reports. Each is linked to the
|
|
11
|
+
* request it happened in.
|
|
12
|
+
*
|
|
13
|
+
* Each request also gets a timeline: the database queries, outgoing HTTP calls, console lines and
|
|
14
|
+
* exceptions that happened while it was handled (see src/timeline and src/instrumentation).
|
|
15
|
+
*
|
|
16
|
+
* This class is the public API. It decides where requests go and which requests are recorded,
|
|
17
|
+
* and leaves the work to the capture (src/capture), exception (src/exceptions), framework
|
|
18
|
+
* (src/integrations) and transport (src/transport) classes.
|
|
19
|
+
*
|
|
20
|
+
* @license MIT
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { ExchangeRecorder } from './capture/exchange-recorder.js';
|
|
24
|
+
import { splitUrl } from './capture/http-message.js';
|
|
25
|
+
import { isIgnoredPath } from './capture/ignored-paths.js';
|
|
26
|
+
import { LOG_PREFIX, VERSION, settings } from './config.js';
|
|
27
|
+
import { TrafficClassifier } from './deployment.js';
|
|
28
|
+
import { ExceptionCapture } from './exceptions/exception-capture.js';
|
|
29
|
+
import { ProcessMonitor } from './exceptions/process-monitor.js';
|
|
30
|
+
import { RequestContext } from './exceptions/request-context.js';
|
|
31
|
+
import { Instrumentation } from './instrumentation/index.js';
|
|
32
|
+
import { isNativePromise } from './instrumentation/patch.js';
|
|
33
|
+
import { TIMELINE_DEFAULTS, Timeline, TimelineSettings } from './timeline/timeline.js';
|
|
34
|
+
import { ExpressAdapter } from './integrations/express.js';
|
|
35
|
+
import { FastifyAdapter } from './integrations/fastify.js';
|
|
36
|
+
import { HttpServerAdapter } from './integrations/http-server.js';
|
|
37
|
+
import { KoaAdapter } from './integrations/koa.js';
|
|
38
|
+
import { ServerBinder } from './integrations/server-binder.js';
|
|
39
|
+
import { ProjectIdentity } from './project.js';
|
|
40
|
+
import { Release } from './release.js';
|
|
41
|
+
import { JobRunner } from './jobs/job-runner.js';
|
|
42
|
+
import { LogCapture } from './logs/log-capture.js';
|
|
43
|
+
import { LoopMonitor } from './runtime/loop-monitor.js';
|
|
44
|
+
import { RuntimeSampler } from './runtime/sampler.js';
|
|
45
|
+
import { UserInfo } from './user.js';
|
|
46
|
+
import { CloudTransport } from './transport/cloud.js';
|
|
47
|
+
import { LocalTransport } from './transport/local.js';
|
|
48
|
+
|
|
49
|
+
const DEFAULT_OPTIONS = {
|
|
50
|
+
maxBodySize: 64 * 1024,
|
|
51
|
+
ignorePaths: [],
|
|
52
|
+
captureExceptions: true,
|
|
53
|
+
sourceContext: true,
|
|
54
|
+
sourceMaps: true,
|
|
55
|
+
runtime: true,
|
|
56
|
+
captureJobs: true,
|
|
57
|
+
logLevel: 'info',
|
|
58
|
+
timeline: true,
|
|
59
|
+
...TIMELINE_DEFAULTS
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Tried in this order by attachTo(): Fastify and Koa apps also have use(), and anything with
|
|
63
|
+
// use() is handled as middleware before falling back to a plain server.
|
|
64
|
+
const ADAPTERS = [FastifyAdapter, KoaAdapter, ExpressAdapter, HttpServerAdapter];
|
|
65
|
+
|
|
66
|
+
// Marks requests already being recorded, so a request that passes through several integrations
|
|
67
|
+
// (or PeekInside instances) is recorded once.
|
|
68
|
+
const kTracked = Symbol('peekInside.tracked');
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* PeekInside Class
|
|
72
|
+
*
|
|
73
|
+
* @class PeekInside
|
|
74
|
+
* @example
|
|
75
|
+
* // Express, Koa, Fastify or an http.Server
|
|
76
|
+
* PeekInside.attach(app);
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* // Recording through middleware only
|
|
80
|
+
* const peekInside = new PeekInside();
|
|
81
|
+
* app.use(peekInside.expressMiddleware());
|
|
82
|
+
*/
|
|
83
|
+
class PeekInside {
|
|
84
|
+
/**
|
|
85
|
+
* Create a PeekInside instance
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} [options] - Configuration options
|
|
88
|
+
* @param {number} [options.maxBodySize=65536] - Maximum bytes captured per request or response body
|
|
89
|
+
* @param {Array<string|RegExp|Function>} [options.ignorePaths=[]] - Paths that are never recorded:
|
|
90
|
+
* string prefixes, regular expressions, or predicates receiving the pathname
|
|
91
|
+
* @param {string} [options.project] - Name in the local dashboard. Defaults to PEEKINSIDE_PROJECT,
|
|
92
|
+
* then the name in your package.json.
|
|
93
|
+
* @param {string} [options.projectRoot] - The project's folder, where its package.json is. By
|
|
94
|
+
* default it is found from the entry script; its own files are told apart from node_modules
|
|
95
|
+
* by it.
|
|
96
|
+
* @param {boolean} [options.local] - Turn the free local dashboard on or off. By default it is on,
|
|
97
|
+
* except when NODE_ENV is "production" or "test", or in CI. PEEKINSIDE_LOCAL=1 or 0 does the same.
|
|
98
|
+
* @param {string} [options.apiKey=process.env.PEEKINSIDE_API_KEY] - PeekInside Cloud API key. When set,
|
|
99
|
+
* requests are encrypted and sent to PeekInside Cloud instead of the local dashboard.
|
|
100
|
+
* @param {string} [options.environment] - Environment label, such as "staging". Defaults to
|
|
101
|
+
* PEEKINSIDE_ENV, then NODE_ENV.
|
|
102
|
+
* @param {string} [options.release] - The deploy that is running, such as a commit hash or
|
|
103
|
+
* "v2.4.0". Defaults to PEEKINSIDE_RELEASE, then the commit your host deployed (Vercel, Render,
|
|
104
|
+
* Railway, Heroku and others set it), then the commit checked out in the project folder.
|
|
105
|
+
* @param {boolean} [options.captureExceptions=true] - Record exceptions automatically: errors that
|
|
106
|
+
* reach Express, Koa or Fastify error handling, uncaught exceptions and unhandled rejections.
|
|
107
|
+
* report() works either way.
|
|
108
|
+
* @param {boolean} [options.sourceContext=true] - Include the source lines around the app's own
|
|
109
|
+
* stack frames in exception records
|
|
110
|
+
* @param {boolean} [options.sourceMaps=true] - Map stack frames of compiled code (TypeScript
|
|
111
|
+
* output, bundles) back to the source files, with the source maps the build wrote
|
|
112
|
+
* @param {boolean} [options.timeline=true] - Record a timeline for each request: its database
|
|
113
|
+
* queries, outgoing calls, console lines and exceptions
|
|
114
|
+
* @param {boolean} [options.captureQueries=true] - pg, mysql2, MongoDB (and Mongoose), Prisma
|
|
115
|
+
* @param {boolean} [options.captureOutgoing=true] - node:http/https requests and fetch
|
|
116
|
+
* @param {boolean} [options.captureLogs=true] - Log lines (the console, pino, winston): on request
|
|
117
|
+
* timelines and the Logs page
|
|
118
|
+
* @param {'debug'|'info'|'warn'|'error'} [options.logLevel='info'] - The lowest level the Logs
|
|
119
|
+
* page records
|
|
120
|
+
* @param {boolean} [options.captureJobs=true] - Runs of node-cron, cron and BullMQ jobs
|
|
121
|
+
* @param {boolean} [options.queryParams=true] - Keep the (masked) values sent with queries
|
|
122
|
+
* @param {number} [options.slowQueryMs=100] - Queries this slow or slower are flagged
|
|
123
|
+
* @param {number} [options.nPlusOneThreshold=5] - A statement run this many times in one request
|
|
124
|
+
* is flagged as an N+1
|
|
125
|
+
* @param {number|false} [options.loopBlockMs=100] - Event-loop blocks this long or longer are
|
|
126
|
+
* recorded on the timelines of the requests in flight; false turns detection off
|
|
127
|
+
* @param {boolean} [options.runtime=true] - Sample the process's memory, CPU and event-loop delay
|
|
128
|
+
* (every 10 s for the local dashboard, every minute for PeekInside Cloud)
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const peekInside = new PeekInside({
|
|
132
|
+
* project: 'orders-api',
|
|
133
|
+
* maxBodySize: 128 * 1024,
|
|
134
|
+
* ignorePaths: ['/health', /^\/metrics/]
|
|
135
|
+
* });
|
|
136
|
+
*/
|
|
137
|
+
constructor(options = {}) {
|
|
138
|
+
/**
|
|
139
|
+
* Effective configuration
|
|
140
|
+
* @type {Object}
|
|
141
|
+
*/
|
|
142
|
+
this.options = { ...DEFAULT_OPTIONS, ...definedOnly(options) };
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether new exchanges are recorded
|
|
146
|
+
* @type {boolean}
|
|
147
|
+
*/
|
|
148
|
+
this.isEnabled = true;
|
|
149
|
+
|
|
150
|
+
// At most one transport is set: cloud mode wins over the local dashboard.
|
|
151
|
+
this._cloud = null;
|
|
152
|
+
this._local = null;
|
|
153
|
+
// Records this instance's exceptions; null when nothing is recorded at all.
|
|
154
|
+
this._exceptions = null;
|
|
155
|
+
// What each request's timeline records; null when timelines are off.
|
|
156
|
+
this._timeline = null;
|
|
157
|
+
// Local mode only: requests that reached a server are counted instead of recorded.
|
|
158
|
+
this._classifier = null;
|
|
159
|
+
// Whether this instance uses the event-loop monitor, and its process sampler.
|
|
160
|
+
this._loopMonitor = false;
|
|
161
|
+
this._sampler = null;
|
|
162
|
+
// Log lines for the Logs page, and job runs.
|
|
163
|
+
this._logs = null;
|
|
164
|
+
this._jobs = null;
|
|
165
|
+
this._servers = new ServerBinder(this, { onListening: (port) => this._onAppListening(port) });
|
|
166
|
+
|
|
167
|
+
const environment = this.options.environment || settings.environment();
|
|
168
|
+
const apiKey = this.options.apiKey || settings.apiKey();
|
|
169
|
+
let project = null;
|
|
170
|
+
const identify = () => project || (project = ProjectIdentity.detect(this.options.project || settings.projectName(), this.options.projectRoot));
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The running release, stored with every request and exception; null when none was found
|
|
174
|
+
* @type {string|null}
|
|
175
|
+
*/
|
|
176
|
+
this.release = Release.detect({ explicit: this.options.release, root: identify().root });
|
|
177
|
+
|
|
178
|
+
if (apiKey) {
|
|
179
|
+
try {
|
|
180
|
+
this._cloud = new CloudTransport({ apiKey, environment, release: this.release, version: VERSION });
|
|
181
|
+
console.log(`${LOG_PREFIX} Sending encrypted requests to PeekInside Cloud (${this._cloud.environment})`);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(`${LOG_PREFIX} ${error.message}. Nothing will be sent to PeekInside Cloud.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!this._cloud) {
|
|
188
|
+
const local = typeof this.options.local === 'boolean' ? this.options.local : settings.localMode();
|
|
189
|
+
if (local) {
|
|
190
|
+
this._local = new LocalTransport({ project: identify(), environment, release: this.release });
|
|
191
|
+
this._classifier = new TrafficClassifier();
|
|
192
|
+
} else {
|
|
193
|
+
this.isEnabled = false;
|
|
194
|
+
if (settings.isProduction()) {
|
|
195
|
+
console.warn(`${LOG_PREFIX} The free local dashboard is for development, so it is off when NODE_ENV=production. Set PEEKINSIDE_API_KEY to use PeekInside Cloud.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this._transport) {
|
|
201
|
+
// In both modes, the project folder tells the app's own stack frames from its dependencies'.
|
|
202
|
+
this._exceptions = new ExceptionCapture({
|
|
203
|
+
root: identify().root,
|
|
204
|
+
automatic: this.options.captureExceptions !== false,
|
|
205
|
+
sourceContext: this.options.sourceContext !== false,
|
|
206
|
+
sourceMaps: this.options.sourceMaps !== false,
|
|
207
|
+
isEnabled: () => this.isEnabled,
|
|
208
|
+
sink: () => this._transport,
|
|
209
|
+
owner: this
|
|
210
|
+
});
|
|
211
|
+
ExceptionCapture.register(this._exceptions);
|
|
212
|
+
ProcessMonitor.update();
|
|
213
|
+
|
|
214
|
+
const blocks = this.options.timeline !== false && this.options.loopBlockMs !== false && this.options.loopBlockMs !== 0;
|
|
215
|
+
if (blocks || this.options.runtime !== false) {
|
|
216
|
+
LoopMonitor.acquire();
|
|
217
|
+
this._loopMonitor = true;
|
|
218
|
+
}
|
|
219
|
+
if (this.options.runtime !== false) {
|
|
220
|
+
const transport = this._transport;
|
|
221
|
+
this._sampler = new RuntimeSampler({
|
|
222
|
+
intervalMs: this._cloud ? 60_000 : 10_000,
|
|
223
|
+
monitor: LoopMonitor.shared,
|
|
224
|
+
onSample: (sample) => transport.addSample(sample)
|
|
225
|
+
}).start();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.options.timeline !== false) {
|
|
229
|
+
this._timeline = new TimelineSettings(this.options, identify().root);
|
|
230
|
+
}
|
|
231
|
+
const logs = this.options.captureLogs !== false;
|
|
232
|
+
if (logs) {
|
|
233
|
+
this._logs = new LogCapture({ level: LogCapture.level(this.options.logLevel), sink: () => this._transport, isEnabled: () => this.isEnabled });
|
|
234
|
+
}
|
|
235
|
+
this._jobs = new JobRunner({
|
|
236
|
+
capture: this._exceptions,
|
|
237
|
+
sink: () => this._transport,
|
|
238
|
+
timeline: () => this._timeline,
|
|
239
|
+
isEnabled: () => this.isEnabled
|
|
240
|
+
});
|
|
241
|
+
Instrumentation.install({
|
|
242
|
+
root: identify().root,
|
|
243
|
+
queries: Boolean(this._timeline && this._timeline.captureQueries),
|
|
244
|
+
outgoing: Boolean(this._timeline && this._timeline.captureOutgoing),
|
|
245
|
+
logs,
|
|
246
|
+
jobs: this.options.captureJobs !== false
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Record a request/response exchange
|
|
253
|
+
*
|
|
254
|
+
* Works with the native `req`/`res` objects of any Node.js HTTP framework. Request bodies are
|
|
255
|
+
* captured as the application reads them, so middleware order does not matter. The exchange is
|
|
256
|
+
* recorded once the response has been sent, or when the connection closes early (as aborted).
|
|
257
|
+
* Paths matching `ignorePaths` are skipped. In local mode, requests that reached the app on a
|
|
258
|
+
* server (a public domain or client address) are not recorded: servers need PeekInside Cloud.
|
|
259
|
+
*
|
|
260
|
+
* @param {http.IncomingMessage} req - The HTTP request
|
|
261
|
+
* @param {http.ServerResponse} res - The HTTP response
|
|
262
|
+
* @param {Function} [next] - Called once tracking is set up (middleware style)
|
|
263
|
+
* @returns {*} The return value of `next`, if given
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* const server = http.createServer((req, res) => {
|
|
267
|
+
* peekInside.intercept(req, res);
|
|
268
|
+
* res.end('Hello World!');
|
|
269
|
+
* });
|
|
270
|
+
*/
|
|
271
|
+
intercept(req, res, next = null) {
|
|
272
|
+
return this._intercept(req, res, next, 'custom');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Records an error, with the request it relates to: the request running now (found through
|
|
277
|
+
* AsyncLocalStorage), or `options.request`.
|
|
278
|
+
*
|
|
279
|
+
* @param {*} error - An Error, or any thrown value
|
|
280
|
+
* @param {Object} [options]
|
|
281
|
+
* @param {http.IncomingMessage} [options.request] - The request the error relates to
|
|
282
|
+
* @param {Object<string, string>} [options.tags] - Up to 32 short labels, such as `{ plan: 'pro' }`
|
|
283
|
+
* @param {*} [options.extra] - Any JSON-safe data; past 16 KB only a preview is kept
|
|
284
|
+
* @param {boolean} [options.handled=true] - Whether the app handled the error
|
|
285
|
+
* @returns {string|null} The exception's id, or null when nothing was recorded (recording is
|
|
286
|
+
* off or paused, the request isn't recorded, or too many exceptions were recorded this second)
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* try {
|
|
290
|
+
* await chargeCard(order);
|
|
291
|
+
* } catch (error) {
|
|
292
|
+
* peekInside.report(error, { tags: { provider: 'stripe' } });
|
|
293
|
+
* res.status(502).json({ error: 'payment_failed' });
|
|
294
|
+
* }
|
|
295
|
+
*/
|
|
296
|
+
report(error, options = {}) {
|
|
297
|
+
return this._exceptions ? this._exceptions.report(error, options || {}) : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Runs `fn` and records how long it took on the request's timeline, as a measure span. Async
|
|
302
|
+
* functions are measured until their promise settles. Outside a request (or with timelines off),
|
|
303
|
+
* just runs `fn`.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} name - Shown on the timeline, such as "render invoice"
|
|
306
|
+
* @param {Function} fn - Sync or async
|
|
307
|
+
* @returns {*} What `fn` returns (for an async `fn`, a promise of the same outcome)
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* const pdf = await peekInside.measure('render invoice', () => renderPdf(order));
|
|
311
|
+
*/
|
|
312
|
+
measure(name, fn) {
|
|
313
|
+
const timeline = this._timeline ? Timeline.current() : null;
|
|
314
|
+
const span = timeline && timeline.settings === this._timeline ? timeline.measure(name) : null;
|
|
315
|
+
if (!span) return fn();
|
|
316
|
+
|
|
317
|
+
let result;
|
|
318
|
+
try {
|
|
319
|
+
result = fn();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
span.end({ error });
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
// A native promise is followed to its outcome; the returned promise settles the same way, so a
|
|
325
|
+
// rejection nobody handles is still reported. Lazy thenables (query builders) are left alone.
|
|
326
|
+
if (isNativePromise(result)) {
|
|
327
|
+
return result.then(
|
|
328
|
+
(value) => {
|
|
329
|
+
span.end();
|
|
330
|
+
return value;
|
|
331
|
+
},
|
|
332
|
+
(error) => {
|
|
333
|
+
span.end({ error });
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
span.end();
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* PeekInside#measure() with the instance that tracks the current request, or else the most
|
|
344
|
+
* recently created instance that records. Without one, just runs `fn`.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} name
|
|
347
|
+
* @param {Function} fn
|
|
348
|
+
* @returns {*} What `fn` returns
|
|
349
|
+
*/
|
|
350
|
+
static measure(name, fn) {
|
|
351
|
+
let instance = null;
|
|
352
|
+
try {
|
|
353
|
+
const context = RequestContext.current();
|
|
354
|
+
const capture = context && context.capture && context.capture.recording ? context.capture : ExceptionCapture.latest();
|
|
355
|
+
instance = capture ? capture.owner : null;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
instance = null;
|
|
358
|
+
}
|
|
359
|
+
return instance ? instance.measure(name, fn) : fn();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The id of the request the calling code runs for: the id its record has in the dashboard, to
|
|
364
|
+
* put in the app's own logs.
|
|
365
|
+
*
|
|
366
|
+
* @returns {string|null} null outside a recorded request
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* logger.info({ requestId: PeekInside.requestId() }, 'Charging card');
|
|
370
|
+
*/
|
|
371
|
+
static requestId() {
|
|
372
|
+
try {
|
|
373
|
+
const context = RequestContext.current();
|
|
374
|
+
return context ? context.requestId : null;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Runs a background job as a recorded job run: the Jobs page lists it with how long it took and
|
|
382
|
+
* how it ended, and its queries, calls, log lines and exceptions link to it. A job that throws
|
|
383
|
+
* (or rejects) is recorded as failed, and its error as an exception, grouped into issues like
|
|
384
|
+
* the others. `fn` runs exactly as it would without PeekInside.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} name - Such as "send-invoices"
|
|
387
|
+
* @param {Function} fn - Sync or async
|
|
388
|
+
* @param {Object} [options]
|
|
389
|
+
* @param {string} [options.schedule] - Its cron expression ("0 * * * *"), when it runs on one:
|
|
390
|
+
* the Jobs page then shows when it misses a run, and PeekInside Cloud can alert you
|
|
391
|
+
* @param {string} [options.timezone] - The schedule's time zone
|
|
392
|
+
* @param {string} [options.queue] - The queue it came from
|
|
393
|
+
* @param {number} [options.attempt=1]
|
|
394
|
+
* @param {*} [options.data] - The job's input, shown with the run (first 8 KB)
|
|
395
|
+
* @returns {*} What fn returns (for an async fn, a promise of the same outcome)
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* cron.schedule('0 * * * *', () => peekInside.job('send-invoices', sendInvoices, { schedule: '0 * * * *' }));
|
|
399
|
+
*/
|
|
400
|
+
job(name, fn, options = {}) {
|
|
401
|
+
return this._jobs ? this._jobs.run(name, fn, options || {}) : fn();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* job() with the instance that tracks the current request, or else the most recently created
|
|
406
|
+
* instance that records. Without one, just runs `fn`.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} name
|
|
409
|
+
* @param {Function} fn
|
|
410
|
+
* @param {Object} [options] - See job()
|
|
411
|
+
* @returns {*} What fn returns
|
|
412
|
+
*/
|
|
413
|
+
static job(name, fn, options = {}) {
|
|
414
|
+
try {
|
|
415
|
+
const context = RequestContext.current();
|
|
416
|
+
const owner = context && context.capture && context.capture.recording ? context.capture.owner : null;
|
|
417
|
+
if (owner) return owner.job(name, fn, options);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
// Fall back to the latest instance.
|
|
420
|
+
}
|
|
421
|
+
return JobRunner.run(name, fn, options || {});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Tells PeekInside who the current request is for. The request and every exception it runs into
|
|
426
|
+
* carry the user, so the dashboard counts the users an issue affects and lists everything one
|
|
427
|
+
* customer ran into. In PeekInside Cloud the user is encrypted with the rest of the request.
|
|
428
|
+
* Call it once the user is known, such as in your authentication middleware.
|
|
429
|
+
*
|
|
430
|
+
* @param {{ id?: string|number, email?: string, name?: string }|string|number|null} user - The
|
|
431
|
+
* user, or just their id; null forgets it
|
|
432
|
+
* @returns {boolean} Whether a request was running to attach it to
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* app.use((req, res, next) => {
|
|
436
|
+
* if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email });
|
|
437
|
+
* next();
|
|
438
|
+
* });
|
|
439
|
+
*/
|
|
440
|
+
static setUser(user) {
|
|
441
|
+
try {
|
|
442
|
+
const context = RequestContext.current();
|
|
443
|
+
if (!context) return false;
|
|
444
|
+
context.user = UserInfo.normalize(user);
|
|
445
|
+
return true;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Same as PeekInside.setUser(): the user belongs to the request running now.
|
|
453
|
+
*
|
|
454
|
+
* @param {{ id?: string|number, email?: string, name?: string }|string|number|null} user
|
|
455
|
+
* @returns {boolean}
|
|
456
|
+
*/
|
|
457
|
+
setUser(user) {
|
|
458
|
+
return PeekInside.setUser(user);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Instruments database modules that automatic patching can't reach: bundled apps, or a
|
|
463
|
+
* monorepo package with its own copy of a driver. Accepts pg, mysql2, mysql2/promise, mongodb,
|
|
464
|
+
* mongoose, a PrismaClient class or instance, and the @prisma/client module; anything else is
|
|
465
|
+
* ignored.
|
|
466
|
+
*
|
|
467
|
+
* @param {...*} modules
|
|
468
|
+
* @returns {PeekInside} This instance, for chaining
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* import pg from 'pg';
|
|
472
|
+
* peekInside.instrument(pg);
|
|
473
|
+
*/
|
|
474
|
+
instrument(...modules) {
|
|
475
|
+
Instrumentation.instrument(modules);
|
|
476
|
+
return this;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Records an error with the instance that tracks the current request, or else the most recently
|
|
481
|
+
* created instance that records. See PeekInside#report().
|
|
482
|
+
*
|
|
483
|
+
* @param {*} error
|
|
484
|
+
* @param {Object} [options] - See PeekInside#report()
|
|
485
|
+
* @returns {string|null} The exception's id, or null when nothing was recorded
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* PeekInside.report(new Error('Cache unavailable'), { extra: { key } });
|
|
489
|
+
*/
|
|
490
|
+
static report(error, options = {}) {
|
|
491
|
+
try {
|
|
492
|
+
const request = options && options.request;
|
|
493
|
+
const context = request
|
|
494
|
+
? RequestContext.of(request) || RequestContext.of(request.raw) || RequestContext.of(request.req)
|
|
495
|
+
: RequestContext.current();
|
|
496
|
+
const owner = context && context.capture && context.capture.recording ? context.capture : null;
|
|
497
|
+
const capture = owner || ExceptionCapture.latest();
|
|
498
|
+
return capture ? capture.report(error, options || {}) : null;
|
|
499
|
+
} catch (failure) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Enable or disable recording
|
|
506
|
+
*
|
|
507
|
+
* @param {boolean} enabled - True to record, false to pause recording
|
|
508
|
+
* @returns {void}
|
|
509
|
+
*/
|
|
510
|
+
setEnabled(enabled) {
|
|
511
|
+
this.isEnabled = Boolean(enabled) && Boolean(this._transport);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Express/Connect middleware that records exchanges
|
|
516
|
+
*
|
|
517
|
+
* @returns {Function} Middleware `(req, res, next)`
|
|
518
|
+
*/
|
|
519
|
+
expressMiddleware() {
|
|
520
|
+
return this._adapter(ExpressAdapter).middleware();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Koa middleware that records exchanges
|
|
525
|
+
*
|
|
526
|
+
* @returns {Function} Koa middleware `(ctx, next)`
|
|
527
|
+
*/
|
|
528
|
+
koaMiddleware() {
|
|
529
|
+
return this._adapter(KoaAdapter).middleware();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Register a Fastify `onRequest` hook that records exchanges
|
|
534
|
+
*
|
|
535
|
+
* @param {Object} fastify - Fastify instance
|
|
536
|
+
* @returns {void}
|
|
537
|
+
*/
|
|
538
|
+
fastifyHook(fastify) {
|
|
539
|
+
this._adapter(FastifyAdapter).attach(fastify);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Record every request handled by a native HTTP server
|
|
544
|
+
*
|
|
545
|
+
* @param {http.Server} server - HTTP server
|
|
546
|
+
* @returns {http.Server} The same server
|
|
547
|
+
*/
|
|
548
|
+
wrapHttpServer(server) {
|
|
549
|
+
this._adapter(HttpServerAdapter).attach(server);
|
|
550
|
+
return server;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Attach to an application and record its traffic
|
|
555
|
+
*
|
|
556
|
+
* Detects Fastify, Koa, Express-like apps (Express, Connect) and native `http.Server`s.
|
|
557
|
+
*
|
|
558
|
+
* @param {Object} app - Application or server instance
|
|
559
|
+
* @param {Object} [options] - `maxBodySize` and `ignorePaths` to merge into the configuration
|
|
560
|
+
* @returns {PeekInside} This instance, for chaining
|
|
561
|
+
*/
|
|
562
|
+
attachTo(app, options = {}) {
|
|
563
|
+
const { maxBodySize, ignorePaths } = options;
|
|
564
|
+
Object.assign(this.options, definedOnly({ maxBodySize, ignorePaths }));
|
|
565
|
+
|
|
566
|
+
const Adapter = ADAPTERS.find((candidate) => candidate.matches(app));
|
|
567
|
+
if (Adapter) {
|
|
568
|
+
this._adapter(Adapter).attach(app);
|
|
569
|
+
} else {
|
|
570
|
+
console.warn(`${LOG_PREFIX} Could not detect the framework. Use expressMiddleware(), koaMiddleware(), fastifyHook() or wrapHttpServer().`);
|
|
571
|
+
}
|
|
572
|
+
return this;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* One-line setup: record every request an app handles
|
|
577
|
+
*
|
|
578
|
+
* @param {Object} app - Express, Koa or Fastify app, or an http.Server
|
|
579
|
+
* @param {Object} [options] - Configuration options (see constructor)
|
|
580
|
+
* @returns {PeekInside} PeekInside instance
|
|
581
|
+
*
|
|
582
|
+
* @example
|
|
583
|
+
* PeekInside.attach(app);
|
|
584
|
+
*/
|
|
585
|
+
static attach(app, options = {}) {
|
|
586
|
+
const peekInside = new PeekInside(options);
|
|
587
|
+
if (app) {
|
|
588
|
+
peekInside.attachTo(app);
|
|
589
|
+
}
|
|
590
|
+
return peekInside;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Send everything still queued
|
|
595
|
+
*
|
|
596
|
+
* Useful before a short-lived process exits (scripts, serverless functions, tests).
|
|
597
|
+
*
|
|
598
|
+
* @returns {Promise<void>}
|
|
599
|
+
*/
|
|
600
|
+
async flush() {
|
|
601
|
+
const transport = this._transport;
|
|
602
|
+
if (transport) {
|
|
603
|
+
await transport.drain();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Stop recording and sending
|
|
609
|
+
*
|
|
610
|
+
* @returns {void}
|
|
611
|
+
*/
|
|
612
|
+
stop() {
|
|
613
|
+
this.isEnabled = false;
|
|
614
|
+
if (this._cloud) {
|
|
615
|
+
this._cloud.stop();
|
|
616
|
+
}
|
|
617
|
+
if (this._local) {
|
|
618
|
+
this._local.stop();
|
|
619
|
+
}
|
|
620
|
+
if (this._exceptions) {
|
|
621
|
+
ExceptionCapture.unregister(this._exceptions);
|
|
622
|
+
ProcessMonitor.update();
|
|
623
|
+
}
|
|
624
|
+
if (this._sampler) {
|
|
625
|
+
this._sampler.stop();
|
|
626
|
+
this._sampler = null;
|
|
627
|
+
}
|
|
628
|
+
if (this._loopMonitor) {
|
|
629
|
+
this._loopMonitor = false;
|
|
630
|
+
LoopMonitor.release();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Where recorded exchanges go, or null when recording is off.
|
|
635
|
+
get _transport() {
|
|
636
|
+
return this._cloud || this._local;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// An adapter for one integration. The first integration used names the app's framework in
|
|
640
|
+
// the local dashboard.
|
|
641
|
+
_adapter(Adapter) {
|
|
642
|
+
if (this._local) {
|
|
643
|
+
this._local.setFramework(Adapter.framework);
|
|
644
|
+
}
|
|
645
|
+
return new Adapter(this, this._servers);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Tracks one request and runs the rest of its handling (`next`) inside its context, so that
|
|
649
|
+
// exceptions during it are linked to it. Integrations pass their name, which says how an error
|
|
650
|
+
// escaping the request was captured.
|
|
651
|
+
_intercept(req, res, next, integration) {
|
|
652
|
+
const proceed = typeof next === 'function' ? next : noop;
|
|
653
|
+
|
|
654
|
+
if (!this.isEnabled || !req || !res || req[kTracked]) {
|
|
655
|
+
return proceed();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const url = req.originalUrl || req.url || '/';
|
|
659
|
+
const { pathname, search } = splitUrl(url);
|
|
660
|
+
if (isIgnoredPath(pathname, this.options.ignorePaths)) {
|
|
661
|
+
return proceed();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
req[kTracked] = true;
|
|
665
|
+
let context = null;
|
|
666
|
+
try {
|
|
667
|
+
this._servers.discover(req);
|
|
668
|
+
const target = { url, pathname, search };
|
|
669
|
+
// The free local dashboard is for development: requests that reached a server are not
|
|
670
|
+
// recorded, and neither are their exceptions.
|
|
671
|
+
const signal = this._classifier ? this._classifier.serverSignal(req) : null;
|
|
672
|
+
if (signal) {
|
|
673
|
+
this._local.noteServerRequest(signal);
|
|
674
|
+
context = new RequestContext({ capture: this._exceptions, req, target, blocked: true, integration });
|
|
675
|
+
} else {
|
|
676
|
+
context = this._track(req, res, target, integration);
|
|
677
|
+
}
|
|
678
|
+
} catch (error) {
|
|
679
|
+
// Monitoring must never break the host application.
|
|
680
|
+
}
|
|
681
|
+
return context ? context.run(proceed) : proceed();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
_track(req, res, target, integration) {
|
|
685
|
+
let context = null;
|
|
686
|
+
const recorder = new ExchangeRecorder(req, res, target, {
|
|
687
|
+
maxBodySize: () => this.options.maxBodySize,
|
|
688
|
+
onExchange: (exchange) => {
|
|
689
|
+
// Exceptions and timeline first: the record is built from the completed exchange.
|
|
690
|
+
if (context) {
|
|
691
|
+
context.complete(exchange);
|
|
692
|
+
}
|
|
693
|
+
const transport = this._transport;
|
|
694
|
+
if (transport) {
|
|
695
|
+
transport.enqueue(exchange);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
context = new RequestContext({ capture: this._exceptions, req, recorder, target, integration });
|
|
700
|
+
recorder.start();
|
|
701
|
+
if (this._timeline) {
|
|
702
|
+
context.timeline = new Timeline(this._timeline, recorder.perfStart);
|
|
703
|
+
}
|
|
704
|
+
// Listeners of the request's and response's events run for this request too.
|
|
705
|
+
context.bind(req);
|
|
706
|
+
context.bind(res);
|
|
707
|
+
return context;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Registers the app with the local dashboard as soon as its port is known.
|
|
711
|
+
_onAppListening(port) {
|
|
712
|
+
if (this._local && this.isEnabled) {
|
|
713
|
+
this._local.start(port);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function noop() {}
|
|
719
|
+
|
|
720
|
+
// Options left undefined must not override the defaults.
|
|
721
|
+
function definedOnly(options) {
|
|
722
|
+
const defined = {};
|
|
723
|
+
for (const key of Object.keys(options || {})) {
|
|
724
|
+
if (options[key] !== undefined) {
|
|
725
|
+
defined[key] = options[key];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return defined;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export default PeekInside;
|