peekinside 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +561 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,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
+ }