peekinside 0.0.0-stage → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +538 -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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class of the framework integrations.
|
|
3
|
+
*
|
|
4
|
+
* An adapter recognizes one kind of app and plugs PeekInside into it. Every adapter hands each
|
|
5
|
+
* request to the same PeekInside#_intercept(), which also runs the rest of the request inside its
|
|
6
|
+
* context, so recording works the same everywhere; adapters only differ in where they hook in,
|
|
7
|
+
* how they see the framework's errors and how they find the app's HTTP server.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plugs PeekInside into one kind of app.
|
|
12
|
+
*
|
|
13
|
+
* Subclasses set `framework`, and implement matches() and attach().
|
|
14
|
+
*/
|
|
15
|
+
export class FrameworkAdapter {
|
|
16
|
+
/**
|
|
17
|
+
* Framework name shown next to the app in the local dashboard.
|
|
18
|
+
*
|
|
19
|
+
* @type {string}
|
|
20
|
+
*/
|
|
21
|
+
static framework = 'node';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether `app` is the kind of app this adapter handles. Detection is duck-typed, so PeekInside
|
|
25
|
+
* never has to import (or depend on) the frameworks themselves.
|
|
26
|
+
*
|
|
27
|
+
* @param {*} app
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
static matches(app) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ _intercept: Function, options: Object }} peekInside - The PeekInside instance that
|
|
36
|
+
* records exchanges
|
|
37
|
+
* @param {import('./server-binder.js').ServerBinder} servers - Tracks the app's HTTP servers
|
|
38
|
+
*/
|
|
39
|
+
constructor(peekInside, servers) {
|
|
40
|
+
this.peekInside = peekInside;
|
|
41
|
+
this.servers = servers;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Starts recording every request the app handles.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} app - An app this adapter matches
|
|
48
|
+
* @returns {void}
|
|
49
|
+
*/
|
|
50
|
+
attach(app) {
|
|
51
|
+
throw new Error(`${this.constructor.name} does not implement attach()`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration for native http (and https) servers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import net from 'net';
|
|
6
|
+
import { FrameworkAdapter } from './framework-adapter.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Records every request a server emits, before the app's own request listeners run.
|
|
10
|
+
*/
|
|
11
|
+
export class HttpServerAdapter extends FrameworkAdapter {
|
|
12
|
+
static framework = 'http';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {*} target
|
|
16
|
+
* @returns {boolean} Whether `target` is a server, or behaves like one
|
|
17
|
+
*/
|
|
18
|
+
static matches(target) {
|
|
19
|
+
return target instanceof net.Server ||
|
|
20
|
+
(Boolean(target) && typeof target.on === 'function' && typeof target.emit === 'function' && typeof target.listen === 'function');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Records the server's requests and learns its port once it listens.
|
|
25
|
+
*
|
|
26
|
+
* @param {import('http').Server} server
|
|
27
|
+
* @returns {void}
|
|
28
|
+
*/
|
|
29
|
+
attach(server) {
|
|
30
|
+
this.servers.bind(server, { interceptRequests: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Koa integration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ExchangeRecorder } from '../capture/exchange-recorder.js';
|
|
6
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
7
|
+
import { FrameworkAdapter } from './framework-adapter.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Records requests through a Koa middleware, and the errors that propagate out of the middleware
|
|
11
|
+
* after it.
|
|
12
|
+
*
|
|
13
|
+
* Errors are seen by catching around `await next()` and rethrowing them unchanged, so Koa still
|
|
14
|
+
* answers them and still emits 'error' (which logs them when the app has no listener). No 'error'
|
|
15
|
+
* listener is added: Koa only logs errors itself while the app has none.
|
|
16
|
+
*/
|
|
17
|
+
export class KoaAdapter extends FrameworkAdapter {
|
|
18
|
+
static framework = 'koa';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {*} app
|
|
22
|
+
* @returns {boolean} Whether `app` looks like a Koa application
|
|
23
|
+
*/
|
|
24
|
+
static matches(app) {
|
|
25
|
+
return Boolean(app) && typeof app.use === 'function' && typeof app.callback === 'function' && Array.isArray(app.middleware);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Koa middleware that records each exchange from the underlying Node request and response.
|
|
30
|
+
* Body parsers for Koa keep the parsed body on ctx.request, not on the Node request, so the
|
|
31
|
+
* recorder is told where to look in case a parser read the body first.
|
|
32
|
+
*
|
|
33
|
+
* Errors are recorded as unhandled: Koa's default handler answers them, unless middleware
|
|
34
|
+
* registered before this one catches them, so attach PeekInside before your own error handler.
|
|
35
|
+
*
|
|
36
|
+
* @returns {Function} Koa middleware `(ctx, next)`
|
|
37
|
+
*/
|
|
38
|
+
middleware() {
|
|
39
|
+
return (ctx, next) => {
|
|
40
|
+
ExchangeRecorder.setBodySource(ctx.req, () => ctx.request.body);
|
|
41
|
+
return this.peekInside._intercept(ctx.req, ctx.res, () => KoaAdapter._next(ctx, next), 'koa');
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Adds the middleware and watches app.listen() for the app's server.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} app - Koa application
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
attach(app) {
|
|
52
|
+
app.use(this.middleware());
|
|
53
|
+
this.servers.patchListen(app);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Runs the downstream middleware and records what it throws, then rethrows it unchanged.
|
|
57
|
+
static async _next(ctx, next) {
|
|
58
|
+
try {
|
|
59
|
+
await next();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
try {
|
|
62
|
+
const context = RequestContext.of(ctx.req);
|
|
63
|
+
if (context && context.capture) {
|
|
64
|
+
context.capture.frameworkError(context, error, { mechanism: 'koa', handled: false });
|
|
65
|
+
}
|
|
66
|
+
} catch (failure) {
|
|
67
|
+
// Recording must never change the error the app sees.
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the app's HTTP servers, to learn the port the app listens on (the local dashboard shows
|
|
3
|
+
* it, and registers the app as soon as it is known) and, for plain http servers, to see every
|
|
4
|
+
* request.
|
|
5
|
+
*
|
|
6
|
+
* Servers are found three ways: passed directly (http.Server, Fastify), returned by a patched
|
|
7
|
+
* app.listen() (Express, Koa), or discovered from the socket of a request the app handled, which
|
|
8
|
+
* catches servers created with http.createServer(app).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Binds each of the app's servers once.
|
|
13
|
+
*/
|
|
14
|
+
export class ServerBinder {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ _intercept: Function }} peekInside - Records exchanges, for servers whose requests
|
|
17
|
+
* are intercepted here
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {(port: number) => void} options.onListening - Called once, with the first port the
|
|
20
|
+
* app listens on
|
|
21
|
+
*/
|
|
22
|
+
constructor(peekInside, { onListening }) {
|
|
23
|
+
this.peekInside = peekInside;
|
|
24
|
+
this._onListening = onListening;
|
|
25
|
+
this._bound = new WeakSet();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Port the app listens on, once known
|
|
29
|
+
* @type {number|null}
|
|
30
|
+
*/
|
|
31
|
+
this.appPort = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wraps app.listen() so the server it creates is bound as soon as it exists.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} app - Express or Koa app
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
patchListen(app) {
|
|
41
|
+
if (typeof app.listen !== 'function') {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const binder = this;
|
|
45
|
+
const originalListen = app.listen;
|
|
46
|
+
|
|
47
|
+
app.listen = function (...args) {
|
|
48
|
+
const server = originalListen.apply(this, args);
|
|
49
|
+
if (server && typeof server.emit === 'function') {
|
|
50
|
+
binder.bind(server);
|
|
51
|
+
}
|
|
52
|
+
return server;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Binds the server a request arrived on, if it isn't bound yet.
|
|
58
|
+
*
|
|
59
|
+
* @param {import('http').IncomingMessage} req
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*/
|
|
62
|
+
discover(req) {
|
|
63
|
+
const socket = req.socket;
|
|
64
|
+
const server = socket && (socket.server || socket._server);
|
|
65
|
+
if (server && !this._bound.has(server)) {
|
|
66
|
+
this.bind(server);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Watches a server for its port. A server is bound only once: binding it again changes nothing.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('http').Server} server
|
|
74
|
+
* @param {Object} [options]
|
|
75
|
+
* @param {boolean} [options.interceptRequests=false] - Also record every request the server
|
|
76
|
+
* emits, for plain http servers where no middleware runs
|
|
77
|
+
* @returns {void}
|
|
78
|
+
*/
|
|
79
|
+
bind(server, { interceptRequests = false } = {}) {
|
|
80
|
+
if (!server || typeof server.emit !== 'function' || this._bound.has(server)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this._bound.add(server);
|
|
84
|
+
|
|
85
|
+
if (interceptRequests) {
|
|
86
|
+
// Wrapping emit() runs PeekInside before every 'request' listener, including the app's, and
|
|
87
|
+
// runs the listeners inside the request's context. An error a listener throws escapes
|
|
88
|
+
// through here unchanged.
|
|
89
|
+
const peekInside = this.peekInside;
|
|
90
|
+
const originalEmit = server.emit;
|
|
91
|
+
server.emit = function (event, req, res) {
|
|
92
|
+
if (event === 'request' && req && res) {
|
|
93
|
+
const args = arguments;
|
|
94
|
+
return peekInside._intercept(req, res, () => originalEmit.apply(this, args), 'http');
|
|
95
|
+
}
|
|
96
|
+
return originalEmit.apply(this, arguments);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof server.once === 'function') {
|
|
101
|
+
const onListening = () => this._noteListening(server);
|
|
102
|
+
if (server.listening) {
|
|
103
|
+
onListening();
|
|
104
|
+
} else {
|
|
105
|
+
server.once('listening', onListening);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The first port seen is the app's port; servers on pipes or sockets have no port.
|
|
111
|
+
_noteListening(server) {
|
|
112
|
+
const address = typeof server.address === 'function' ? server.address() : null;
|
|
113
|
+
const port = address && typeof address === 'object' ? address.port : null;
|
|
114
|
+
if (port === null || this.appPort !== null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.appPort = port;
|
|
118
|
+
this._onListening(port);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A job run's context: what a request's context is to a request, for a background job. Code the
|
|
3
|
+
* job runs (queries, outgoing calls, log lines, exceptions) finds the run through it and links
|
|
4
|
+
* to it, with the same AsyncLocalStorage the requests use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
9
|
+
|
|
10
|
+
const NO_TARGET = Object.freeze({ url: '', pathname: '', search: '' });
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One job run.
|
|
14
|
+
*/
|
|
15
|
+
export class JobContext extends RequestContext {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} fields
|
|
18
|
+
* @param {import('../exceptions/exception-capture.js').ExceptionCapture} fields.capture
|
|
19
|
+
* @param {string} fields.name - The job's name
|
|
20
|
+
*/
|
|
21
|
+
constructor({ capture, name }) {
|
|
22
|
+
super({ capture, req: null, recorder: null, target: NO_TARGET, integration: 'job' });
|
|
23
|
+
/**
|
|
24
|
+
* The id of the run's record
|
|
25
|
+
* @type {string}
|
|
26
|
+
*/
|
|
27
|
+
this.jobRunId = randomUUID();
|
|
28
|
+
this.jobName = name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A job has no request to show with its exceptions. */
|
|
32
|
+
snapshot() {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns {null} */
|
|
37
|
+
requestLine() {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of a background job (a scheduled task, a queue worker's job, or code wrapped in
|
|
3
|
+
* PeekInside.job()), as the Jobs page lists it (see the data contract).
|
|
4
|
+
*
|
|
5
|
+
* Readable in PeekInside Cloud, like a request's method and status: the job's name, its queue and
|
|
6
|
+
* schedule, when it ran, for how long, how it ended, its attempt and counters. Sealed: the error it
|
|
7
|
+
* failed with, the user, the job's data and its timeline.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A recorded job run.
|
|
12
|
+
*/
|
|
13
|
+
export class JobRunRecord {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} fields
|
|
16
|
+
* @param {string} fields.id - UUID
|
|
17
|
+
* @param {number} fields.occurredAt - When the run started (ms since the epoch)
|
|
18
|
+
* @param {string} fields.environment
|
|
19
|
+
* @param {string|null} [fields.release=null]
|
|
20
|
+
* @param {string} fields.name - Such as "send-invoices"
|
|
21
|
+
* @param {string|null} [fields.queue=null] - The queue it came from (BullMQ)
|
|
22
|
+
* @param {string|null} [fields.schedule=null] - Its cron expression, when it runs on one
|
|
23
|
+
* @param {string|null} [fields.timezone=null] - The schedule's time zone
|
|
24
|
+
* @param {'cron'|'queue'|'manual'} fields.trigger
|
|
25
|
+
* @param {'ok'|'failed'} fields.status
|
|
26
|
+
* @param {number|null} fields.duration - ms
|
|
27
|
+
* @param {number} [fields.attempt=1]
|
|
28
|
+
* @param {number} [fields.exceptionCount=0]
|
|
29
|
+
* @param {Object} [fields.stats] - Timeline counters, as requests have them
|
|
30
|
+
* @param {Object} fields.summary - `{ error: { name, message } | null, user }` (sealed in Cloud)
|
|
31
|
+
* @param {Object} fields.detail - `{ data, timeline, exceptions }` (sealed in Cloud)
|
|
32
|
+
*/
|
|
33
|
+
constructor({ id, occurredAt, environment, release = null, name, queue = null, schedule = null, timezone = null, trigger, status, duration, attempt = 1, exceptionCount = 0, stats, summary, detail }) {
|
|
34
|
+
this.id = id;
|
|
35
|
+
this.occurredAt = occurredAt;
|
|
36
|
+
this.environment = environment;
|
|
37
|
+
this.release = release;
|
|
38
|
+
this.name = name;
|
|
39
|
+
this.queue = queue;
|
|
40
|
+
this.schedule = schedule;
|
|
41
|
+
this.timezone = timezone;
|
|
42
|
+
this.trigger = trigger;
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.duration = duration;
|
|
45
|
+
this.attempt = attempt;
|
|
46
|
+
this.exceptionCount = exceptionCount;
|
|
47
|
+
if (stats) this.stats = stats;
|
|
48
|
+
this.summary = summary;
|
|
49
|
+
this.detail = detail;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The plain form, as the local hub stores it.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Object}
|
|
56
|
+
*/
|
|
57
|
+
toJSON() {
|
|
58
|
+
return { ...this };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The sealed form, for PeekInside Cloud.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('../crypto.js').Sealer} sealer
|
|
65
|
+
* @returns {Promise<Object>}
|
|
66
|
+
*/
|
|
67
|
+
async seal(sealer) {
|
|
68
|
+
const { summary, detail, ...meta } = this.toJSON();
|
|
69
|
+
return {
|
|
70
|
+
...meta,
|
|
71
|
+
summary: await sealer.seal(JSON.stringify(summary), JobRunRecord.summaryAad(this.id)),
|
|
72
|
+
detail: await sealer.seal(JSON.stringify(detail), JobRunRecord.detailAad(this.id))
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {import('../crypto.js').SyncSealer} sealer
|
|
78
|
+
* @returns {Object}
|
|
79
|
+
*/
|
|
80
|
+
sealSync(sealer) {
|
|
81
|
+
const { summary, detail, ...meta } = this.toJSON();
|
|
82
|
+
return {
|
|
83
|
+
...meta,
|
|
84
|
+
summary: sealer.seal(JSON.stringify(summary), JobRunRecord.summaryAad(this.id)),
|
|
85
|
+
detail: sealer.seal(JSON.stringify(detail), JobRunRecord.detailAad(this.id))
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} id
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
static summaryAad(id) {
|
|
94
|
+
return `job:summary:${id}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} id
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
static detailAad(id) {
|
|
102
|
+
return `job:detail:${id}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a background job as a recorded job run: its own context (so its queries, calls, log lines
|
|
3
|
+
* and exceptions link to it), a timeline, and a record of how it went (see JobRunRecord). The
|
|
4
|
+
* job's function runs exactly as it would without PeekInside: same arguments, same result, same
|
|
5
|
+
* error thrown.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { performance } from 'perf_hooks';
|
|
9
|
+
import { ExceptionCapture } from '../exceptions/exception-capture.js';
|
|
10
|
+
import { truncate, truncateBytes } from '../exceptions/limits.js';
|
|
11
|
+
import { SafeJson } from '../exceptions/safe-json.js';
|
|
12
|
+
import { isNativePromise } from '../instrumentation/patch.js';
|
|
13
|
+
import { Timeline } from '../timeline/timeline.js';
|
|
14
|
+
import { JobContext } from './job-context.js';
|
|
15
|
+
import { JobRunRecord } from './job-run-record.js';
|
|
16
|
+
|
|
17
|
+
const MAX_NAME = 128;
|
|
18
|
+
const MAX_SCHEDULE = 128;
|
|
19
|
+
const MAX_DATA = 8 * 1024;
|
|
20
|
+
const MAX_ERROR_MESSAGE = 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} JobOptions
|
|
24
|
+
* @property {string} [schedule] - Its cron expression, when it runs on one (monitors it)
|
|
25
|
+
* @property {string} [timezone] - The schedule's time zone
|
|
26
|
+
* @property {string} [queue] - The queue it came from
|
|
27
|
+
* @property {number} [attempt=1]
|
|
28
|
+
* @property {*} [data] - The job's input, shown with the run (first 8 KB)
|
|
29
|
+
* @property {'cron'|'queue'|'manual'} [trigger]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runs jobs for one PeekInside instance.
|
|
34
|
+
*/
|
|
35
|
+
export class JobRunner {
|
|
36
|
+
/**
|
|
37
|
+
* Runs `fn` as a job run of the instance tracking the code running now, else the most recently
|
|
38
|
+
* created instance that records. Without one, just runs `fn`.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} name
|
|
41
|
+
* @param {Function} fn
|
|
42
|
+
* @param {JobOptions} [options]
|
|
43
|
+
* @returns {*} What fn returns
|
|
44
|
+
*/
|
|
45
|
+
static run(name, fn, options = {}) {
|
|
46
|
+
const capture = ExceptionCapture.latest();
|
|
47
|
+
const owner = capture ? capture.owner : null;
|
|
48
|
+
const runner = owner && owner._jobs;
|
|
49
|
+
return runner ? runner.run(name, fn, options) : fn();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object} options
|
|
54
|
+
* @param {import('../exceptions/exception-capture.js').ExceptionCapture} options.capture
|
|
55
|
+
* @param {() => Object|null} options.sink - The instance's transport
|
|
56
|
+
* @param {() => import('../timeline/timeline.js').TimelineSettings|null} options.timeline
|
|
57
|
+
* @param {() => boolean} options.isEnabled
|
|
58
|
+
*/
|
|
59
|
+
constructor({ capture, sink, timeline, isEnabled }) {
|
|
60
|
+
this.capture = capture;
|
|
61
|
+
this._sink = sink;
|
|
62
|
+
this._timeline = timeline;
|
|
63
|
+
this._isEnabled = isEnabled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} name
|
|
68
|
+
* @param {Function} fn
|
|
69
|
+
* @param {JobOptions} [options]
|
|
70
|
+
* @returns {*} What fn returns (for an async fn, a promise of the same outcome)
|
|
71
|
+
*/
|
|
72
|
+
run(name, fn, options = {}) {
|
|
73
|
+
if (typeof fn !== 'function') throw new TypeError('PeekInside.job() needs a function to run');
|
|
74
|
+
const sink = this._sink();
|
|
75
|
+
if (!sink || sink.disabled || !this._isEnabled()) return fn();
|
|
76
|
+
|
|
77
|
+
const context = new JobContext({ capture: this.capture, name: jobName(name) });
|
|
78
|
+
const settings = this._timeline();
|
|
79
|
+
if (settings) context.timeline = new Timeline(settings);
|
|
80
|
+
const run = { context, options: options || {}, occurredAt: Date.now(), started: performance.now() };
|
|
81
|
+
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = context.run(fn);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this._finish(run, error, true);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (isNativePromise(result) || (result && typeof result.then === 'function')) {
|
|
90
|
+
return Promise.resolve(result).then(
|
|
91
|
+
(value) => {
|
|
92
|
+
this._finish(run, null, false);
|
|
93
|
+
return value;
|
|
94
|
+
},
|
|
95
|
+
(error) => {
|
|
96
|
+
this._finish(run, error, true);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
this._finish(run, null, false);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Records the run; never throws into the job.
|
|
106
|
+
_finish({ context, options, occurredAt, started }, error, failed) {
|
|
107
|
+
try {
|
|
108
|
+
if (failed) this.capture.jobError(context, error);
|
|
109
|
+
const sink = this._sink();
|
|
110
|
+
if (!sink || sink.disabled) return;
|
|
111
|
+
const closed = context.timeline ? context.timeline.close() : null;
|
|
112
|
+
const schedule = typeof options.schedule === 'string' && options.schedule.trim() ? truncate(options.schedule.trim(), MAX_SCHEDULE) : null;
|
|
113
|
+
const queue = typeof options.queue === 'string' && options.queue ? truncate(options.queue, MAX_NAME) : null;
|
|
114
|
+
const record = new JobRunRecord({
|
|
115
|
+
id: context.jobRunId,
|
|
116
|
+
occurredAt,
|
|
117
|
+
environment: sink.environment,
|
|
118
|
+
release: sink.release || null,
|
|
119
|
+
name: context.jobName,
|
|
120
|
+
queue,
|
|
121
|
+
schedule,
|
|
122
|
+
timezone: typeof options.timezone === 'string' && options.timezone ? truncate(options.timezone, 64) : null,
|
|
123
|
+
trigger: ['cron', 'queue', 'manual'].includes(options.trigger) ? options.trigger : schedule ? 'cron' : queue ? 'queue' : 'manual',
|
|
124
|
+
status: failed ? 'failed' : 'ok',
|
|
125
|
+
duration: Math.round((performance.now() - started) * 100) / 100,
|
|
126
|
+
attempt: Number.isInteger(options.attempt) && options.attempt > 0 ? options.attempt : 1,
|
|
127
|
+
exceptionCount: context.exceptionCount,
|
|
128
|
+
stats: closed ? closed.stats : undefined,
|
|
129
|
+
summary: { error: failed ? describe(error) : null, ...(context.user ? { user: context.user } : {}) },
|
|
130
|
+
detail: {
|
|
131
|
+
data: preview(options.data),
|
|
132
|
+
exceptions: context.links.slice(),
|
|
133
|
+
...(closed && closed.timeline ? { timeline: closed.timeline } : {})
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
context.release();
|
|
137
|
+
sink.enqueueRecord(record);
|
|
138
|
+
} catch (failure) {
|
|
139
|
+
// The job's own outcome is unaffected.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function jobName(name) {
|
|
145
|
+
const text = typeof name === 'string' ? name.trim() : '';
|
|
146
|
+
return truncate(text || 'job', MAX_NAME);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function describe(error) {
|
|
150
|
+
if (error instanceof Error || (error && typeof error === 'object' && typeof error.message === 'string')) {
|
|
151
|
+
let name = 'Error';
|
|
152
|
+
try {
|
|
153
|
+
name = String(error.name || (error.constructor && error.constructor.name) || 'Error');
|
|
154
|
+
} catch (failure) {
|
|
155
|
+
name = 'Error';
|
|
156
|
+
}
|
|
157
|
+
return { name: truncate(name, 200), message: truncate(String(error.message), MAX_ERROR_MESSAGE) };
|
|
158
|
+
}
|
|
159
|
+
return { name: 'ThrownValue', message: truncate(String(error), MAX_ERROR_MESSAGE) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The job's input as JSON-safe data, or a preview of its JSON past 8 KB.
|
|
163
|
+
function preview(data) {
|
|
164
|
+
if (data === undefined) return null;
|
|
165
|
+
const { json, truncated } = SafeJson.stringify(data, MAX_DATA + 1);
|
|
166
|
+
if (json === undefined) return null;
|
|
167
|
+
if (truncated || Buffer.byteLength(json) > MAX_DATA) return { truncated: true, preview: truncateBytes(json, 2048) };
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(json);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|