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.
@@ -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
- * For the exception list: the request's method and path.
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 { method: this.method, path: this.path };
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
- return { id: this.requestId, ...this.recorder.snapshot(LIMITS.requestBody) };
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 || RequestSnapshot.partsOf(this._req);
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
- if (this.user) exchange.user = this.user;
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 = RequestSnapshot.partsOf(this._req);
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 and log lines per batch.
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
- return new IngestPayload({ project, instance, requests, exceptions, jobs, logs, samples: body.samples, blocked: body.blocked, hasExceptions: Array.isArray(body.exceptions) });
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 = { id: text(value.id, 128) || null, email: text(value.email, 254) || null, name: text(value.name, 128) || null };
328
- return user.id || user.email || user.name ? user : null;
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
- return { method: value.method.slice(0, 16), path: value.path.slice(0, 16384) };
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) {
@@ -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 {{ requests?: SegmentLog, exceptions?: SegmentLog, jobs?: SegmentLog, logs?: SegmentLog }} [logs] - Logs
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
- * /p/<project id>/exceptions and /p/<project id>/queries)
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');