peekinside 1.2.4 → 1.3.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/README.md +13 -2
- package/index.d.ts +44 -13
- package/package.json +10 -1
- package/src/PeekInside.js +93 -17
- package/src/alerts/rule-matcher.js +158 -0
- package/src/capture/exchange-recorder.js +5 -12
- package/src/capture/http-message.js +17 -0
- package/src/crypto.js +26 -2
- package/src/dumps/dump-capture.js +100 -0
- package/src/dumps/dump-record.js +57 -0
- package/src/dumps/dump-value.js +428 -0
- package/src/exceptions/exception-capture.js +5 -4
- package/src/exceptions/exception-record.js +11 -1
- package/src/exceptions/request-context.js +118 -10
- package/src/hub/ingest-payload.js +89 -8
- package/src/hub/project-log.js +71 -4
- package/src/hub/server.js +19 -8
- package/src/hub/spool-ingester.js +1 -0
- package/src/hub/store.js +44 -1
- package/src/instrumentation/index.js +48 -3
- package/src/instrumentation/jobs.js +114 -6
- package/src/instrumentation/loggers.js +78 -4
- package/src/integrations/fastify.js +18 -1
- package/src/integrations/hono.js +209 -0
- package/src/integrations/nest.js +277 -0
- package/src/integrations/server-binder.js +9 -6
- package/src/logs/log-capture.js +5 -5
- package/src/logs/log-record.js +2 -2
- package/src/record.js +6 -2
- package/src/runtime/request-counter.js +40 -0
- package/src/runtime/sampler.js +120 -6
- package/src/timeline/timeline.js +24 -3
- package/src/transport/batch.js +4 -1
- package/src/transport/cloud.js +34 -3
- package/src/transport/local.js +7 -5
- package/src/user.js +18 -7
|
@@ -62,7 +62,9 @@ export class ExceptionRecord {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* The sealed form, for PeekInside Cloud: the summary and the detail are encrypted, bound to this
|
|
65
|
-
* exception's id so they can't be swapped with another record's.
|
|
65
|
+
* exception's id so they can't be swapped with another record's. The fingerprint (a hash of the
|
|
66
|
+
* error's name and where it was thrown, see fingerprint.js) goes alongside: it tells Cloud a new
|
|
67
|
+
* error from a known one, for alerts, without saying what either is.
|
|
66
68
|
*
|
|
67
69
|
* @param {import('../crypto.js').Sealer} sealer - Sealer of the batch
|
|
68
70
|
* @returns {Promise<Object>}
|
|
@@ -71,6 +73,7 @@ export class ExceptionRecord {
|
|
|
71
73
|
const { summary, detail, ...meta } = this.toJSON();
|
|
72
74
|
return {
|
|
73
75
|
...meta,
|
|
76
|
+
...this._fingerprint(),
|
|
74
77
|
summary: await sealer.seal(JSON.stringify(summary), ExceptionRecord.summaryAad(this.id)),
|
|
75
78
|
detail: await sealer.seal(JSON.stringify(detail), ExceptionRecord.detailAad(this.id))
|
|
76
79
|
};
|
|
@@ -86,11 +89,18 @@ export class ExceptionRecord {
|
|
|
86
89
|
const { summary, detail, ...meta } = this.toJSON();
|
|
87
90
|
return {
|
|
88
91
|
...meta,
|
|
92
|
+
...this._fingerprint(),
|
|
89
93
|
summary: sealer.seal(JSON.stringify(summary), ExceptionRecord.summaryAad(this.id)),
|
|
90
94
|
detail: sealer.seal(JSON.stringify(detail), ExceptionRecord.detailAad(this.id))
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
// The fingerprint field of the sealed form; none when there isn't a valid one.
|
|
99
|
+
_fingerprint() {
|
|
100
|
+
const fingerprint = this.summary?.fingerprint;
|
|
101
|
+
return typeof fingerprint === 'string' && /^[0-9a-f]{16}$/.test(fingerprint) ? { fingerprint } : {};
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
/**
|
|
95
105
|
* How the record of the request it happened in lists it, so the request inspector can link to it.
|
|
96
106
|
*
|
|
@@ -14,15 +14,24 @@
|
|
|
14
14
|
* The context also keeps the request's exception bookkeeping: which errors were already recorded
|
|
15
15
|
* (each error object is recorded once per request), the records whose outcome is still open (see
|
|
16
16
|
* ExceptionCapture), and the count and links that go into the request's record.
|
|
17
|
+
*
|
|
18
|
+
* Integrations that know what the framework matched the request to note it here (noteRoute()):
|
|
19
|
+
* the route pattern, the code that handles it and the route parameters. The request's record and
|
|
20
|
+
* its exceptions carry them.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
20
24
|
import { BodyDecoder } from '../capture/body-decoder.js';
|
|
21
|
-
import { parseQuery, splitUrl } from '../capture/http-message.js';
|
|
22
|
-
import { LIMITS } from './limits.js';
|
|
25
|
+
import { parseQuery, splitUrl, stringParams } from '../capture/http-message.js';
|
|
26
|
+
import { LIMITS, truncate } from './limits.js';
|
|
23
27
|
|
|
24
28
|
const storage = new AsyncLocalStorage();
|
|
25
29
|
const kContext = Symbol('peekInside.context');
|
|
30
|
+
// What a route pattern and a handler name may take up.
|
|
31
|
+
const MAX_ROUTE = 512;
|
|
32
|
+
const MAX_HANDLER = 256;
|
|
33
|
+
// Dumps listed in a request's record (the Dumps page has them all).
|
|
34
|
+
const MAX_DUMP_LINKS = 20;
|
|
26
35
|
// The request an exception last escaped from, until the current job ends.
|
|
27
36
|
let escaped = null;
|
|
28
37
|
|
|
@@ -83,7 +92,7 @@ export class RequestContext {
|
|
|
83
92
|
* @param {boolean} [fields.blocked=false] - The request reached a server in local mode: neither
|
|
84
93
|
* it nor its exceptions are recorded
|
|
85
94
|
* @param {string} [fields.integration='custom'] - Where tracking started: "express", "koa",
|
|
86
|
-
* "fastify", "http" (a server's emit()) or "custom" (intercept())
|
|
95
|
+
* "fastify", "nest", "hono", "http" (a server's emit()) or "custom" (intercept())
|
|
87
96
|
*/
|
|
88
97
|
constructor({ capture, req, recorder = null, target, blocked = false, integration = 'custom' }) {
|
|
89
98
|
this.capture = capture;
|
|
@@ -119,8 +128,26 @@ export class RequestContext {
|
|
|
119
128
|
* @type {import('../user.js').User|null}
|
|
120
129
|
*/
|
|
121
130
|
this.user = null;
|
|
131
|
+
/**
|
|
132
|
+
* The route pattern the framework matched ("/users/:id"), once an integration noted it
|
|
133
|
+
* @type {string|null}
|
|
134
|
+
*/
|
|
135
|
+
this.route = null;
|
|
136
|
+
/**
|
|
137
|
+
* The code that handles the request ("UsersController.findOne"), once an integration noted it
|
|
138
|
+
* @type {string|null}
|
|
139
|
+
*/
|
|
140
|
+
this.handler = null;
|
|
141
|
+
/**
|
|
142
|
+
* Dumps made during the request (PeekInside.dump()), for its record
|
|
143
|
+
* @type {{ id: string, label: string|null, type: string, preview: string }[]}
|
|
144
|
+
*/
|
|
145
|
+
this.dumps = [];
|
|
122
146
|
|
|
123
147
|
this._req = req;
|
|
148
|
+
// Route parameters an integration noted (or a function returning them), for frameworks that
|
|
149
|
+
// don't put them on the Node.js request.
|
|
150
|
+
this._params = undefined;
|
|
124
151
|
// What remains for exceptions after the request finished (see release()).
|
|
125
152
|
this._after = null;
|
|
126
153
|
|
|
@@ -169,12 +196,81 @@ export class RequestContext {
|
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
/**
|
|
172
|
-
*
|
|
199
|
+
* Notes what the framework matched the request to, as an integration learns it. Each part that is
|
|
200
|
+
* given replaces the one noted before; null forgets it, and parts left out stay as they are.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object} match
|
|
203
|
+
* @param {string|null} [match.route] - The route pattern, such as "/users/:id"
|
|
204
|
+
* @param {string|null} [match.handler] - The code that handles it, such as "UsersController.findOne"
|
|
205
|
+
* @param {Object|(() => Object)|null} [match.params] - The route parameters, or a function that
|
|
206
|
+
* returns them when they are needed, for frameworks that keep them off the Node.js request
|
|
207
|
+
* @returns {void}
|
|
208
|
+
*/
|
|
209
|
+
noteRoute({ route, handler, params } = {}) {
|
|
210
|
+
if (route !== undefined) this.route = typeof route === 'string' && route ? truncate(route, MAX_ROUTE) : null;
|
|
211
|
+
if (handler !== undefined) this.handler = typeof handler === 'string' && handler ? truncate(handler, MAX_HANDLER) : null;
|
|
212
|
+
if (params !== undefined) this._params = params;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The route parameters an integration noted, as strings.
|
|
217
|
+
*
|
|
218
|
+
* @returns {Object<string, string>|null} null when none were noted
|
|
219
|
+
*/
|
|
220
|
+
routeParams() {
|
|
221
|
+
let params = this._params;
|
|
222
|
+
if (typeof params === 'function') {
|
|
223
|
+
try {
|
|
224
|
+
params = params();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
params = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return params && typeof params === 'object' ? stringParams(params) : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* For the exception list: the request's method and path, and its route and handler when known.
|
|
173
234
|
*
|
|
174
|
-
* @returns {{ method: string, path: string }}
|
|
235
|
+
* @returns {{ method: string, path: string, route?: string, handler?: string }}
|
|
175
236
|
*/
|
|
176
237
|
requestLine() {
|
|
177
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
method: this.method,
|
|
240
|
+
path: this.path,
|
|
241
|
+
...(this.route ? { route: this.route } : {}),
|
|
242
|
+
...(this.handler ? { handler: this.handler } : {})
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Counts a dump made during the request: listed on its record, and placed on its timeline.
|
|
248
|
+
*
|
|
249
|
+
* @param {import('../dumps/dump-record.js').DumpRecord} record
|
|
250
|
+
* @returns {void}
|
|
251
|
+
*/
|
|
252
|
+
noteDump(record) {
|
|
253
|
+
if (this.dumps.length < MAX_DUMP_LINKS) this.dumps.push(record.link());
|
|
254
|
+
if (this.timeline) this.timeline.dump(record);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Adds what the request's record shows of the request beyond the exchange itself: who it was for,
|
|
259
|
+
* its route and handler, the dumps made during it, and route parameters the Node.js request
|
|
260
|
+
* didn't carry.
|
|
261
|
+
*
|
|
262
|
+
* @param {import('../capture/exchange-recorder.js').Exchange} exchange - Changed in place
|
|
263
|
+
* @returns {void}
|
|
264
|
+
*/
|
|
265
|
+
describe(exchange) {
|
|
266
|
+
if (this.user) exchange.user = this.user;
|
|
267
|
+
if (this.route) exchange.route = this.route;
|
|
268
|
+
if (this.handler) exchange.handler = this.handler;
|
|
269
|
+
if (this.dumps.length) exchange.dumps = this.dumps.slice();
|
|
270
|
+
if (this._params !== undefined && (!exchange.params || !Object.keys(exchange.params).length)) {
|
|
271
|
+
const params = this.routeParams();
|
|
272
|
+
if (params) exchange.params = params;
|
|
273
|
+
}
|
|
178
274
|
}
|
|
179
275
|
|
|
180
276
|
/**
|
|
@@ -185,9 +281,11 @@ export class RequestContext {
|
|
|
185
281
|
*/
|
|
186
282
|
snapshot() {
|
|
187
283
|
if (this.recorder && !this.recorder.recorded) {
|
|
188
|
-
|
|
284
|
+
const snapshot = { id: this.requestId, ...this.recorder.snapshot(LIMITS.requestBody) };
|
|
285
|
+
if (this._params !== undefined && !Object.keys(snapshot.params).length) snapshot.params = this.routeParams() || {};
|
|
286
|
+
return snapshot;
|
|
189
287
|
}
|
|
190
|
-
const after = this._after ||
|
|
288
|
+
const after = this._after || this._parts();
|
|
191
289
|
return {
|
|
192
290
|
id: this.requestId,
|
|
193
291
|
method: this.method,
|
|
@@ -223,7 +321,7 @@ export class RequestContext {
|
|
|
223
321
|
complete(exchange) {
|
|
224
322
|
if (this.capture) this.capture.finish(this, exchange);
|
|
225
323
|
this.completeTimeline(exchange);
|
|
226
|
-
|
|
324
|
+
this.describe(exchange);
|
|
227
325
|
this.release();
|
|
228
326
|
}
|
|
229
327
|
|
|
@@ -253,11 +351,21 @@ export class RequestContext {
|
|
|
253
351
|
*/
|
|
254
352
|
release() {
|
|
255
353
|
this.finished = true;
|
|
256
|
-
this._after =
|
|
354
|
+
this._after = this._parts();
|
|
355
|
+
// A function returning the parameters may hold the framework's request.
|
|
356
|
+
if (typeof this._params === 'function') this._params = this.routeParams();
|
|
257
357
|
this._req = null;
|
|
258
358
|
this.recorder = null;
|
|
259
359
|
this.timeline = null;
|
|
260
360
|
}
|
|
361
|
+
|
|
362
|
+
// The small parts of the request a snapshot keeps, with the parameters an integration noted when
|
|
363
|
+
// the request has none of its own.
|
|
364
|
+
_parts() {
|
|
365
|
+
const parts = RequestSnapshot.partsOf(this._req);
|
|
366
|
+
if (this._params !== undefined && !Object.keys(parts.params).length) parts.params = this.routeParams() || {};
|
|
367
|
+
return parts;
|
|
368
|
+
}
|
|
261
369
|
}
|
|
262
370
|
|
|
263
371
|
/**
|
|
@@ -13,9 +13,10 @@ import { PROJECT_ID } from './store.js';
|
|
|
13
13
|
export const MAX_BATCH = 1000;
|
|
14
14
|
// Exceptions per batch.
|
|
15
15
|
export const MAX_EXCEPTIONS_BATCH = 500;
|
|
16
|
-
// Job runs
|
|
16
|
+
// Job runs, log lines and dumps per batch.
|
|
17
17
|
export const MAX_JOBS_BATCH = 1000;
|
|
18
18
|
export const MAX_LOGS_BATCH = 2000;
|
|
19
|
+
export const MAX_DUMPS_BATCH = 1000;
|
|
19
20
|
// Request ids are UUIDs in practice. They appear in URLs and index files, so only safe
|
|
20
21
|
// characters are accepted.
|
|
21
22
|
export const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -27,19 +28,38 @@ const FINGERPRINT = /^[0-9a-f]{16}$/;
|
|
|
27
28
|
const STAT_KEYS = ['queries', 'queryTime', 'slowQueries', 'nPlusOne', 'duplicates', 'http', 'httpTime', 'logs', 'loopBlock'];
|
|
28
29
|
const STAT_TIMES = new Set(['queryTime', 'httpTime', 'loopBlock']);
|
|
29
30
|
const MAX_STAT = 1e9;
|
|
30
|
-
const MECHANISMS = new Set(['express', 'koa', 'fastify', 'http', 'uncaught', 'unhandledRejection', 'report', 'job']);
|
|
31
|
+
const MECHANISMS = new Set(['express', 'koa', 'fastify', 'nest', 'hono', 'http', 'uncaught', 'unhandledRejection', 'report', 'job']);
|
|
31
32
|
const JOB_STATUSES = new Set(['ok', 'failed']);
|
|
32
33
|
const JOB_TRIGGERS = new Set(['cron', 'queue', 'manual']);
|
|
33
34
|
const LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
|
|
34
|
-
const LOG_SOURCES = new Set(['console', 'pino', 'winston']);
|
|
35
|
+
const LOG_SOURCES = new Set(['console', 'pino', 'winston', 'nest']);
|
|
36
|
+
// What the SDK keeps of a route pattern and a handler name.
|
|
37
|
+
const MAX_ROUTE = 512;
|
|
38
|
+
const MAX_HANDLER = 256;
|
|
35
39
|
// What the index keeps of a log line; the line in full is its detail.
|
|
36
40
|
const LOG_PREVIEW = 500;
|
|
41
|
+
// What the index keeps of a dump: its label, the type and a one-line preview of its value.
|
|
42
|
+
const DUMP_LABEL = 120;
|
|
43
|
+
const DUMP_TYPE = 64;
|
|
44
|
+
const DUMP_PREVIEW = 200;
|
|
37
45
|
// What release.js produces.
|
|
38
46
|
const RELEASE = /^[A-Za-z0-9._:@/+-]{1,64}$/;
|
|
39
47
|
// Process samples per batch (the SDK sends one every 10 seconds, more after the hub was down).
|
|
40
48
|
const MAX_SAMPLES = 120;
|
|
41
49
|
const SAMPLE_BYTES = ['rss', 'heapUsed', 'heapTotal', 'external'];
|
|
42
50
|
const SAMPLE_DELAYS = ['loopP50', 'loopP99', 'loopMax'];
|
|
51
|
+
// Later additions, each optional (older SDKs don't send them): the field, and its largest value.
|
|
52
|
+
const SAMPLE_EXTRAS = [
|
|
53
|
+
['arrayBuffers', 1e13],
|
|
54
|
+
['heapLimit', 1e13],
|
|
55
|
+
['elu', 100],
|
|
56
|
+
['gcCount', 1e7],
|
|
57
|
+
['gcMs', 1e7],
|
|
58
|
+
['gcMax', 1e7],
|
|
59
|
+
['handles', 1e7],
|
|
60
|
+
['requests', 1e9],
|
|
61
|
+
['inFlight', 1e7]
|
|
62
|
+
];
|
|
43
63
|
|
|
44
64
|
/**
|
|
45
65
|
* A validated ingest batch: `{ project, instance, requests, exceptions, samples, blocked }`.
|
|
@@ -65,10 +85,14 @@ export class IngestPayload {
|
|
|
65
85
|
}
|
|
66
86
|
const jobs = body.jobs != null ? body.jobs : [];
|
|
67
87
|
const logs = body.logs != null ? body.logs : [];
|
|
88
|
+
const dumps = body.dumps != null ? body.dumps : [];
|
|
68
89
|
if (!Array.isArray(jobs) || jobs.length > MAX_JOBS_BATCH || !Array.isArray(logs) || logs.length > MAX_LOGS_BATCH) {
|
|
69
90
|
return null;
|
|
70
91
|
}
|
|
71
|
-
|
|
92
|
+
if (!Array.isArray(dumps) || dumps.length > MAX_DUMPS_BATCH) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return new IngestPayload({ project, instance, requests, exceptions, jobs, logs, dumps, samples: body.samples, blocked: body.blocked, hasExceptions: Array.isArray(body.exceptions) });
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
/**
|
|
@@ -81,12 +105,13 @@ export class IngestPayload {
|
|
|
81
105
|
* @param {Array} [parts.exceptions] - Exception records as sent
|
|
82
106
|
* @param {Array} [parts.jobs] - Job runs as sent
|
|
83
107
|
* @param {Array} [parts.logs] - Log lines as sent
|
|
108
|
+
* @param {Array} [parts.dumps] - Dumps as sent
|
|
84
109
|
* @param {*} [parts.samples] - Process samples (memory, CPU, event-loop delay), as sent
|
|
85
110
|
* @param {*} parts.blocked - Counts of unrecorded server requests, as sent
|
|
86
111
|
* @param {boolean} [parts.hasExceptions=false] - Whether the batch had an exception list (SDKs
|
|
87
112
|
* from before exceptions don't send one)
|
|
88
113
|
*/
|
|
89
|
-
constructor({ project, instance, requests, exceptions = [], jobs = [], logs = [], samples, blocked, hasExceptions = false }) {
|
|
114
|
+
constructor({ project, instance, requests, exceptions = [], jobs = [], logs = [], dumps = [], samples, blocked, hasExceptions = false }) {
|
|
90
115
|
this.project = project;
|
|
91
116
|
this.instance = instance;
|
|
92
117
|
/**
|
|
@@ -127,6 +152,12 @@ export class IngestPayload {
|
|
|
127
152
|
this.logs = logs.map(readLog).filter(Boolean);
|
|
128
153
|
this.rejectedJobs = jobs.length - this.jobs.length;
|
|
129
154
|
this.rejectedLogs = logs.length - this.logs.length;
|
|
155
|
+
/**
|
|
156
|
+
* The valid dumps, flattened into what the store keeps (the value's tree is the detail)
|
|
157
|
+
* @type {Object[]}
|
|
158
|
+
*/
|
|
159
|
+
this.dumps = dumps.map(readDump).filter(Boolean);
|
|
160
|
+
this.rejectedDumps = dumps.length - this.dumps.length;
|
|
130
161
|
/**
|
|
131
162
|
* The valid counts of server requests the SDK didn't record
|
|
132
163
|
* @type {{ kind: 'host'|'client', value: string, count: number }[]}
|
|
@@ -184,6 +215,8 @@ function readRecord(value) {
|
|
|
184
215
|
url: text(summary.url, 16384),
|
|
185
216
|
path: text(summary.path, 16384),
|
|
186
217
|
contentType: text(summary.contentType, 256),
|
|
218
|
+
route: text(summary.route, MAX_ROUTE) || null,
|
|
219
|
+
handler: text(summary.handler, MAX_HANDLER) || null,
|
|
187
220
|
user: readUser(summary.user),
|
|
188
221
|
detail
|
|
189
222
|
};
|
|
@@ -287,6 +320,43 @@ function readLog(value) {
|
|
|
287
320
|
};
|
|
288
321
|
}
|
|
289
322
|
|
|
323
|
+
// A dump: its fields, the start of its value for the list, and the value's tree as its detail. The
|
|
324
|
+
// tree is the dashboard's to read; the hub only keeps it.
|
|
325
|
+
function readDump(value) {
|
|
326
|
+
if (!value || typeof value !== 'object') return null;
|
|
327
|
+
if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id) || !Number.isFinite(value.occurredAt)) return null;
|
|
328
|
+
if (!optionalId(value.requestId) || !optionalId(value.jobRunId)) return null;
|
|
329
|
+
const summary = value.summary && typeof value.summary === 'object' ? value.summary : {};
|
|
330
|
+
const detail = value.detail && typeof value.detail === 'object' && !Array.isArray(value.detail) ? value.detail : {};
|
|
331
|
+
const tree = detail.value && typeof detail.value === 'object' && !Array.isArray(detail.value) && typeof detail.value.t === 'string' ? detail.value : { t: 'undef' };
|
|
332
|
+
return {
|
|
333
|
+
id: value.id,
|
|
334
|
+
occurredAt: value.occurredAt,
|
|
335
|
+
environment: text(value.environment, 32) || 'development',
|
|
336
|
+
release: release(value.release),
|
|
337
|
+
requestId: value.requestId || null,
|
|
338
|
+
jobRunId: value.jobRunId || null,
|
|
339
|
+
label: text(value.label, DUMP_LABEL) || null,
|
|
340
|
+
location: readCaller(value.location),
|
|
341
|
+
type: text(summary.type, DUMP_TYPE) || 'value',
|
|
342
|
+
preview: text(summary.preview, DUMP_PREVIEW),
|
|
343
|
+
truncated: summary.truncated === true,
|
|
344
|
+
detail: { value: tree }
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Where in the app something happened: an absolute file, the same relative to the project, a line.
|
|
349
|
+
function readCaller(value) {
|
|
350
|
+
if (!value || typeof value !== 'object' || typeof value.file !== 'string' || !value.file) return null;
|
|
351
|
+
return {
|
|
352
|
+
file: value.file.slice(0, 4096),
|
|
353
|
+
relativeFile: typeof value.relativeFile === 'string' ? value.relativeFile.slice(0, 4096) : null,
|
|
354
|
+
line: positiveInt(value.line),
|
|
355
|
+
column: positiveInt(value.column),
|
|
356
|
+
function: typeof value.function === 'string' ? value.function.slice(0, 1024) : null
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
290
360
|
function optionalId(value) {
|
|
291
361
|
return value === null || value === undefined || (typeof value === 'string' && REQUEST_ID.test(value));
|
|
292
362
|
}
|
|
@@ -307,6 +377,10 @@ function readSamples(value) {
|
|
|
307
377
|
const number = sample[key];
|
|
308
378
|
clean[key] = Number.isFinite(number) && number >= 0 && number <= 1e7 ? number : null;
|
|
309
379
|
}
|
|
380
|
+
for (const [key, max] of SAMPLE_EXTRAS) {
|
|
381
|
+
const number = sample[key];
|
|
382
|
+
clean[key] = Number.isFinite(number) && number >= 0 && number <= max ? number : null;
|
|
383
|
+
}
|
|
310
384
|
return [clean];
|
|
311
385
|
});
|
|
312
386
|
}
|
|
@@ -324,13 +398,20 @@ function readLocation(value) {
|
|
|
324
398
|
// Who a request or exception was for: short strings only, null when it names nobody.
|
|
325
399
|
function readUser(value) {
|
|
326
400
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
327
|
-
const user = {
|
|
328
|
-
|
|
401
|
+
const user = {
|
|
402
|
+
id: text(value.id, 128) || null,
|
|
403
|
+
email: text(value.email, 254) || null,
|
|
404
|
+
name: text(value.name, 128) || null,
|
|
405
|
+
phone: text(value.phone, 32) || null
|
|
406
|
+
};
|
|
407
|
+
return user.id || user.email || user.name || user.phone ? user : null;
|
|
329
408
|
}
|
|
330
409
|
|
|
331
410
|
function readRequestLine(value) {
|
|
332
411
|
if (!value || typeof value !== 'object' || typeof value.method !== 'string' || typeof value.path !== 'string') return null;
|
|
333
|
-
|
|
412
|
+
const route = text(value.route, MAX_ROUTE);
|
|
413
|
+
const handler = text(value.handler, MAX_HANDLER);
|
|
414
|
+
return { method: value.method.slice(0, 16), path: value.path.slice(0, 16384), ...(route ? { route } : {}), ...(handler ? { handler } : {}) };
|
|
334
415
|
}
|
|
335
416
|
|
|
336
417
|
function positiveInt(value) {
|
package/src/hub/project-log.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* exception-state.json fingerprints the dashboard marked resolved
|
|
9
9
|
* jobs/00000001.* job runs, in the same format
|
|
10
10
|
* logs/00000001.* log lines: the index keeps the start of each line, the details the rest
|
|
11
|
+
* dumps/00000001.* dumps: the index keeps a preview of each value, the details its tree
|
|
11
12
|
*
|
|
12
13
|
* Process samples (memory, CPU, event-loop delay) are kept in memory only, for the Runtime page:
|
|
13
14
|
* the last six hours of each of the project's recent processes.
|
|
@@ -33,13 +34,15 @@ const MAX_BLOCKED_ENTRIES = 10;
|
|
|
33
34
|
const MAX_RUNTIME_SAMPLES = 2160;
|
|
34
35
|
const MAX_RUNTIME_PROCESSES = 8;
|
|
35
36
|
// Fields a summary may contain, in the order they are written.
|
|
36
|
-
const SUMMARY_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'method', 'status', 'duration', 'requestSize', 'responseSize', 'aborted', 'exceptionCount', 'stats', 'url', 'path', 'contentType', 'user'];
|
|
37
|
+
const SUMMARY_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'method', 'status', 'duration', 'requestSize', 'responseSize', 'aborted', 'exceptionCount', 'stats', 'url', 'path', 'contentType', 'route', 'handler', 'user'];
|
|
37
38
|
const EXCEPTION_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'handled', 'fatal', 'requestId', 'jobRunId', 'name', 'message', 'code', 'fingerprint', 'mechanism', 'location', 'request', 'job', 'user'];
|
|
38
39
|
const JOB_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'name', 'queue', 'schedule', 'timezone', 'trigger', 'status', 'duration', 'attempt', 'exceptionCount', 'stats', 'error', 'user'];
|
|
39
40
|
const LOG_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'level', 'source', 'requestId', 'jobRunId', 'message'];
|
|
41
|
+
const DUMP_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'requestId', 'jobRunId', 'label', 'location', 'type', 'preview', 'truncated'];
|
|
40
42
|
const EXCEPTIONS_FOLDER = 'exceptions';
|
|
41
43
|
const JOBS_FOLDER = 'jobs';
|
|
42
44
|
const LOGS_FOLDER = 'logs';
|
|
45
|
+
const DUMPS_FOLDER = 'dumps';
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* @typedef {Object} ProjectInfo
|
|
@@ -71,7 +74,8 @@ export class ProjectLog {
|
|
|
71
74
|
* @param {number} limits.exceptionSegmentSize - Exceptions per segment file
|
|
72
75
|
* @param {number} limits.maxJobs - Job runs kept
|
|
73
76
|
* @param {number} limits.maxLogs - Log lines kept
|
|
74
|
-
* @param {
|
|
77
|
+
* @param {number} limits.maxDumps - Dumps kept
|
|
78
|
+
* @param {{ requests?: SegmentLog, exceptions?: SegmentLog, jobs?: SegmentLog, logs?: SegmentLog, dumps?: SegmentLog }} [logs] - Logs
|
|
75
79
|
* read from disk
|
|
76
80
|
*/
|
|
77
81
|
constructor(dir, info, limits, logs = {}) {
|
|
@@ -82,6 +86,7 @@ export class ProjectLog {
|
|
|
82
86
|
this.exceptions = logs.exceptions || new SegmentLog(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits));
|
|
83
87
|
this.jobs = logs.jobs || new SegmentLog(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits));
|
|
84
88
|
this.logs = logs.logs || new SegmentLog(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits));
|
|
89
|
+
this.dumps = logs.dumps || new SegmentLog(path.join(dir, DUMPS_FOLDER), ProjectLog._dumpOptions(info, limits));
|
|
85
90
|
this.exceptionState = new ExceptionState(dir);
|
|
86
91
|
// Processes running the project, by pid. In memory only: they check in again after a restart.
|
|
87
92
|
this.instances = new Map();
|
|
@@ -111,7 +116,8 @@ export class ProjectLog {
|
|
|
111
116
|
seqBase: 1,
|
|
112
117
|
exceptionSeqBase: 1,
|
|
113
118
|
jobSeqBase: 1,
|
|
114
|
-
logSeqBase: 1
|
|
119
|
+
logSeqBase: 1,
|
|
120
|
+
dumpSeqBase: 1
|
|
115
121
|
}, limits);
|
|
116
122
|
project.save();
|
|
117
123
|
return project;
|
|
@@ -138,13 +144,15 @@ export class ProjectLog {
|
|
|
138
144
|
exceptionSeqBase: Number(saved.exceptionSeqBase) || 1,
|
|
139
145
|
jobSeqBase: Number(saved.jobSeqBase) || 1,
|
|
140
146
|
logSeqBase: Number(saved.logSeqBase) || 1,
|
|
147
|
+
dumpSeqBase: Number(saved.dumpSeqBase) || 1,
|
|
141
148
|
...(isBlockedInfo(saved.blocked) ? { blocked: saved.blocked } : {})
|
|
142
149
|
};
|
|
143
150
|
return new ProjectLog(dir, info, limits, {
|
|
144
151
|
requests: SegmentLog.load(dir, ProjectLog._requestOptions(info, limits)),
|
|
145
152
|
exceptions: SegmentLog.load(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits)),
|
|
146
153
|
jobs: SegmentLog.load(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits)),
|
|
147
|
-
logs: SegmentLog.load(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits))
|
|
154
|
+
logs: SegmentLog.load(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits)),
|
|
155
|
+
dumps: SegmentLog.load(path.join(dir, DUMPS_FOLDER), ProjectLog._dumpOptions(info, limits))
|
|
148
156
|
});
|
|
149
157
|
}
|
|
150
158
|
|
|
@@ -416,6 +424,48 @@ export class ProjectLog {
|
|
|
416
424
|
this.save();
|
|
417
425
|
}
|
|
418
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Stores new dumps; ones already stored (same id) are skipped.
|
|
429
|
+
*
|
|
430
|
+
* @param {Object[]} records - Validated dumps, with their detail (the value's tree)
|
|
431
|
+
* @returns {Object[]}
|
|
432
|
+
*/
|
|
433
|
+
appendDumps(records) {
|
|
434
|
+
return this.dumps.append(records);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Dump summaries, newest first; oldest first when `after` is given.
|
|
439
|
+
*
|
|
440
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
441
|
+
* @returns {{ dumps: Object[], hasMore: boolean }}
|
|
442
|
+
*/
|
|
443
|
+
listDumps(query) {
|
|
444
|
+
const { entries, hasMore } = this.dumps.list(query);
|
|
445
|
+
return { dumps: entries, hasMore };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* One dump's value (its tree).
|
|
450
|
+
*
|
|
451
|
+
* @param {string} dumpId
|
|
452
|
+
* @returns {Object|null}
|
|
453
|
+
*/
|
|
454
|
+
dumpDetail(dumpId) {
|
|
455
|
+
return this.dumps.detail(dumpId);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Deletes all dumps.
|
|
460
|
+
*
|
|
461
|
+
* @returns {void}
|
|
462
|
+
*/
|
|
463
|
+
clearDumps() {
|
|
464
|
+
this.dumps.clear();
|
|
465
|
+
this.info.dumpSeqBase = this.dumps.seqBase;
|
|
466
|
+
this.save();
|
|
467
|
+
}
|
|
468
|
+
|
|
419
469
|
/**
|
|
420
470
|
* Deletes the project's folder.
|
|
421
471
|
*
|
|
@@ -444,6 +494,7 @@ export class ProjectLog {
|
|
|
444
494
|
const newest = this.requests.newest;
|
|
445
495
|
const newestException = this.exceptions.newest;
|
|
446
496
|
const newestJob = this.jobs.newest;
|
|
497
|
+
const newestDump = this.dumps.newest;
|
|
447
498
|
return {
|
|
448
499
|
id: this.info.id,
|
|
449
500
|
name: this.info.name,
|
|
@@ -457,6 +508,8 @@ export class ProjectLog {
|
|
|
457
508
|
jobCount: this.jobs.size,
|
|
458
509
|
lastJobAt: newestJob ? newestJob.occurredAt : null,
|
|
459
510
|
logCount: this.logs.size,
|
|
511
|
+
dumpCount: this.dumps.size,
|
|
512
|
+
lastDumpAt: newestDump ? newestDump.occurredAt : null,
|
|
460
513
|
online: instances.length > 0,
|
|
461
514
|
instances,
|
|
462
515
|
blocked: this.info.blocked && now - this.info.blocked.lastAt < BLOCKED_NOTICE_MS ? this.info.blocked : null
|
|
@@ -495,6 +548,7 @@ export class ProjectLog {
|
|
|
495
548
|
this.exceptions.enforceRetention();
|
|
496
549
|
this.jobs.enforceRetention();
|
|
497
550
|
this.logs.enforceRetention();
|
|
551
|
+
this.dumps.enforceRetention();
|
|
498
552
|
}
|
|
499
553
|
|
|
500
554
|
static _requestOptions(info, limits) {
|
|
@@ -509,6 +563,10 @@ export class ProjectLog {
|
|
|
509
563
|
return { maxEntries: limits.maxLogs, segmentSize: Math.max(10, Math.ceil(limits.maxLogs / 4)), seqBase: info.logSeqBase || 1, pick: pickLog };
|
|
510
564
|
}
|
|
511
565
|
|
|
566
|
+
static _dumpOptions(info, limits) {
|
|
567
|
+
return { maxEntries: limits.maxDumps, segmentSize: Math.max(10, Math.ceil(limits.maxDumps / 4)), seqBase: info.dumpSeqBase || 1, pick: pickDump };
|
|
568
|
+
}
|
|
569
|
+
|
|
512
570
|
static _exceptionOptions(info, limits) {
|
|
513
571
|
return {
|
|
514
572
|
maxEntries: limits.maxExceptions,
|
|
@@ -533,6 +591,9 @@ function pickSummary(source) {
|
|
|
533
591
|
// Stored before releases (or users) were recorded.
|
|
534
592
|
if (summary.release === undefined) summary.release = null;
|
|
535
593
|
if (summary.user === undefined) summary.user = null;
|
|
594
|
+
// Stored before route patterns were recorded, or from a framework that doesn't name them.
|
|
595
|
+
if (summary.route === undefined) summary.route = null;
|
|
596
|
+
if (summary.handler === undefined) summary.handler = null;
|
|
536
597
|
return summary;
|
|
537
598
|
}
|
|
538
599
|
|
|
@@ -552,6 +613,12 @@ function pickJob(source) {
|
|
|
552
613
|
return summary;
|
|
553
614
|
}
|
|
554
615
|
|
|
616
|
+
function pickDump(source) {
|
|
617
|
+
const summary = {};
|
|
618
|
+
for (const field of DUMP_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
|
|
619
|
+
return summary;
|
|
620
|
+
}
|
|
621
|
+
|
|
555
622
|
function pickLog(source) {
|
|
556
623
|
const summary = {};
|
|
557
624
|
for (const field of LOG_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
|
package/src/hub/server.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The local hub's HTTP server, on 127.0.0.1 only.
|
|
3
3
|
*
|
|
4
|
-
* GET / Dashboard page (also /p/<project id>,
|
|
5
|
-
* /
|
|
4
|
+
* GET / Dashboard page (also /p/<project id>, and its
|
|
5
|
+
* /exceptions, /routes, /queries, /logs, /jobs, /dumps and /runtime)
|
|
6
6
|
* POST /api/ingest Requests and exceptions from the SDK in each app, and
|
|
7
7
|
* counts of server requests it didn't record (free plan)
|
|
8
8
|
* GET /api/status Hub version and settings
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* GET /api/projects/:id/logs Log lines (their start), paged like requests
|
|
21
21
|
* GET /api/projects/:id/logs/:lid One log line in full
|
|
22
22
|
* DELETE /api/projects/:id/logs Delete a project's log lines
|
|
23
|
+
* GET /api/projects/:id/dumps Dumps (a preview of each value), paged like requests
|
|
24
|
+
* GET /api/projects/:id/dumps/:did One dump's value
|
|
25
|
+
* DELETE /api/projects/:id/dumps Delete a project's dumps
|
|
23
26
|
* GET /api/projects/:id/exception-state Resolved issues, with a version
|
|
24
27
|
* PUT /api/projects/:id/exception-state Save them, if the version is still the current one
|
|
25
28
|
* DELETE /api/projects/:id Forget a project
|
|
@@ -38,7 +41,7 @@ import { readFileSync } from 'fs';
|
|
|
38
41
|
import { HUB_API_VERSION, HUB_APP_ID } from '../config.js';
|
|
39
42
|
import { ExceptionState } from './exception-state.js';
|
|
40
43
|
import { IngestPayload, REQUEST_ID } from './ingest-payload.js';
|
|
41
|
-
import { PROJECT_ID } from './store.js';
|
|
44
|
+
import { DEFAULT_MAX_DUMPS, PROJECT_ID } from './store.js';
|
|
42
45
|
|
|
43
46
|
const MAX_INGEST_BYTES = 32 * 1024 * 1024;
|
|
44
47
|
// 5000 fingerprints with their timestamps take about 200 KB.
|
|
@@ -46,9 +49,9 @@ const MAX_STATE_BYTES = 1024 * 1024;
|
|
|
46
49
|
const DEFAULT_PAGE = 500;
|
|
47
50
|
const MAX_PAGE = 1000;
|
|
48
51
|
// /api/projects/:id, then optionally a collection and an item.
|
|
49
|
-
const PROJECT_ROUTE = /^\/api\/projects\/([^/]+)(?:\/(requests|exceptions|exception-state|runtime|jobs|logs)(?:\/([^/]+))?)?$/;
|
|
52
|
+
const PROJECT_ROUTE = /^\/api\/projects\/([^/]+)(?:\/(requests|exceptions|exception-state|runtime|jobs|logs|dumps)(?:\/([^/]+))?)?$/;
|
|
50
53
|
// The dashboard page: the project list, a project's requests, its exceptions and its queries.
|
|
51
|
-
const SHELL_ROUTE = /^\/p\/[a-z0-9-]{1,64}(?:\/(?:exceptions|queries|runtime|logs|jobs))?\/?$/;
|
|
54
|
+
const SHELL_ROUTE = /^\/p\/[a-z0-9-]{1,64}(?:\/(?:exceptions|routes|queries|runtime|logs|jobs|dumps))?\/?$/;
|
|
52
55
|
|
|
53
56
|
const BASE_HEADERS = {
|
|
54
57
|
'X-Content-Type-Options': 'nosniff',
|
|
@@ -150,7 +153,9 @@ export class HubServer {
|
|
|
150
153
|
dataDir: this.dataDir,
|
|
151
154
|
siteUrl: this.siteUrl,
|
|
152
155
|
maxRequests: this.store.maxRequests,
|
|
153
|
-
maxExceptions: this.store.maxExceptions
|
|
156
|
+
maxExceptions: this.store.maxExceptions,
|
|
157
|
+
// Also tells the dashboard this hub keeps dumps (older ones don't).
|
|
158
|
+
maxDumps: DEFAULT_MAX_DUMPS
|
|
154
159
|
};
|
|
155
160
|
}
|
|
156
161
|
|
|
@@ -243,6 +248,7 @@ export class HubServer {
|
|
|
243
248
|
this.store.addSamples(payload.project.id, payload.instance, payload.samples);
|
|
244
249
|
const addedJobs = this.store.appendJobs(payload.project.id, payload.jobs);
|
|
245
250
|
const addedLogs = this.store.appendLogs(payload.project.id, payload.logs);
|
|
251
|
+
const addedDumps = this.store.appendDumps(payload.project.id, payload.dumps);
|
|
246
252
|
// SDKs from before exceptions get the answer they know.
|
|
247
253
|
return sendJson(res, 200, payload.hasExceptions
|
|
248
254
|
? {
|
|
@@ -251,9 +257,10 @@ export class HubServer {
|
|
|
251
257
|
acceptedExceptions: addedExceptions.length,
|
|
252
258
|
rejectedExceptions: payload.rejectedExceptions,
|
|
253
259
|
acceptedJobs: addedJobs.length,
|
|
254
|
-
acceptedLogs: addedLogs.length
|
|
260
|
+
acceptedLogs: addedLogs.length,
|
|
261
|
+
acceptedDumps: addedDumps.length
|
|
255
262
|
}
|
|
256
|
-
: { accepted: added.length, rejected: payload.rejected, acceptedJobs: addedJobs.length, acceptedLogs: addedLogs.length });
|
|
263
|
+
: { accepted: added.length, rejected: payload.rejected, acceptedJobs: addedJobs.length, acceptedLogs: addedLogs.length, acceptedDumps: addedDumps.length });
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// Every /api/ route except ingest; the caller has checked the origin.
|
|
@@ -299,6 +306,10 @@ export class HubServer {
|
|
|
299
306
|
if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.logDetail(projectId, id), 'This log line is no longer stored.');
|
|
300
307
|
return this._collection(req, res, query, () => this.store.listLogs(projectId, pageOf(query)), () => this.store.clearLogs(projectId));
|
|
301
308
|
}
|
|
309
|
+
if (collection === 'dumps') {
|
|
310
|
+
if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.dumpDetail(projectId, id), 'This dump is no longer stored.');
|
|
311
|
+
return this._collection(req, res, query, () => this.store.listDumps(projectId, pageOf(query)), () => this.store.clearDumps(projectId));
|
|
312
|
+
}
|
|
302
313
|
if (collection === 'runtime') {
|
|
303
314
|
if (item !== undefined) return sendJson(res, 404, { error: 'Not found' });
|
|
304
315
|
return read ? sendJson(res, 200, this.store.runtime(projectId)) : methodNotAllowed(res, 'GET, HEAD');
|