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.
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Samples the process now and then: memory (to spot leaks, as growth over hours), CPU and the
3
- * event-loop delay. Samples are numbers only, and go to the dashboard with the other records:
4
- * every 10 seconds to the local dashboard, every minute to PeekInside Cloud.
2
+ * Samples the process now and then: memory (to spot leaks, as growth over hours, and how close the
3
+ * heap is to its limit), CPU, the event loop (its delay, and how busy it was), garbage collection,
4
+ * open handles and the requests it served. Samples are numbers only, and go to the dashboard with
5
+ * the other records: every 10 seconds to the local dashboard, every minute to PeekInside Cloud.
5
6
  */
6
7
 
7
- import { performance } from 'perf_hooks';
8
+ import { PerformanceObserver, performance } from 'perf_hooks';
9
+ import v8 from 'v8';
8
10
  import { RequestContext } from '../exceptions/request-context.js';
9
11
 
10
12
  /**
@@ -18,8 +20,73 @@ import { RequestContext } from '../exceptions/request-context.js';
18
20
  * @property {number|null} loopP50 - Event-loop delay since the last sample, ms
19
21
  * @property {number|null} loopP99
20
22
  * @property {number|null} loopMax
23
+ * @property {number|null} arrayBuffers - Bytes of ArrayBuffers and Buffers (part of external)
24
+ * @property {number|null} heapLimit - Bytes the heap may grow to before the process runs out of memory
25
+ * @property {number|null} elu - Percent of the time since the last sample the event loop was busy
26
+ * @property {number|null} gcCount - Garbage collections since the last sample
27
+ * @property {number|null} gcMs - Time they paused the process, ms
28
+ * @property {number|null} gcMax - The longest of them, ms
29
+ * @property {number|null} handles - Open handles and requests (sockets, timers, files) now
30
+ * @property {number|null} requests - Requests this process finished since the last sample
31
+ * @property {number|null} inFlight - Requests open when the sample was taken
21
32
  */
22
33
 
34
+ /**
35
+ * Garbage collections since the last look: how many, and how long they paused the process.
36
+ */
37
+ class GcWatch {
38
+ constructor() {
39
+ this.observer = null;
40
+ this.reset();
41
+ }
42
+
43
+ /**
44
+ * @returns {void}
45
+ */
46
+ start() {
47
+ if (this.observer) return;
48
+ try {
49
+ this.observer = new PerformanceObserver((list) => {
50
+ for (const entry of list.getEntries()) {
51
+ this.count++;
52
+ this.ms += entry.duration;
53
+ if (entry.duration > this.max) this.max = entry.duration;
54
+ }
55
+ });
56
+ // Outside any request: collections belong to the process, not to the request that started it.
57
+ RequestContext.outside(() => this.observer.observe({ entryTypes: ['gc'] }));
58
+ } catch (error) {
59
+ this.observer = null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @returns {void}
65
+ */
66
+ stop() {
67
+ if (this.observer) this.observer.disconnect();
68
+ this.observer = null;
69
+ }
70
+
71
+ /**
72
+ * The collections since the last call, or null when they can't be watched.
73
+ *
74
+ * @returns {{ count: number, ms: number, max: number }|null}
75
+ */
76
+ take() {
77
+ if (!this.observer) return null;
78
+ const taken = { count: this.count, ms: round(this.ms), max: round(this.max) };
79
+ this.reset();
80
+ return taken;
81
+ }
82
+
83
+ reset() {
84
+ this.count = 0;
85
+ this.ms = 0;
86
+ this.max = 0;
87
+ }
88
+ }
89
+
23
90
  /**
24
91
  * Takes a sample every `intervalMs` and hands it over.
25
92
  */
@@ -29,14 +96,18 @@ export class RuntimeSampler {
29
96
  * @param {number} options.intervalMs
30
97
  * @param {(sample: RuntimeSample) => void} options.onSample
31
98
  * @param {import('./loop-monitor.js').LoopMonitor} options.monitor - Measures the delay
99
+ * @param {import('./request-counter.js').RequestCounter} [options.requests] - Counts the requests served
32
100
  */
33
- constructor({ intervalMs, onSample, monitor }) {
101
+ constructor({ intervalMs, onSample, monitor, requests = null }) {
34
102
  this.intervalMs = intervalMs;
35
103
  this.onSample = onSample;
36
104
  this.monitor = monitor;
105
+ this.requests = requests;
37
106
  this.timer = null;
107
+ this.gc = new GcWatch();
38
108
  this.lastAt = performance.now();
39
109
  this.lastCpu = process.cpuUsage();
110
+ this.lastElu = loopUtilization();
40
111
  }
41
112
 
42
113
  /**
@@ -46,6 +117,8 @@ export class RuntimeSampler {
46
117
  if (this.timer) return this;
47
118
  this.lastAt = performance.now();
48
119
  this.lastCpu = process.cpuUsage();
120
+ this.lastElu = loopUtilization();
121
+ this.gc.start();
49
122
  this.timer = RequestContext.outside(() => setInterval(() => this.take(), this.intervalMs));
50
123
  if (typeof this.timer.unref === 'function') this.timer.unref();
51
124
  return this;
@@ -57,6 +130,7 @@ export class RuntimeSampler {
57
130
  stop() {
58
131
  clearInterval(this.timer);
59
132
  this.timer = null;
133
+ this.gc.stop();
60
134
  }
61
135
 
62
136
  /**
@@ -70,9 +144,14 @@ export class RuntimeSampler {
70
144
  const elapsed = Math.max(1, now - this.lastAt);
71
145
  const cpu = process.cpuUsage(this.lastCpu);
72
146
  const delay = this.monitor ? this.monitor.delaySince(this.lastAt) : null;
147
+ const elu = loopUtilization();
148
+ const busy = elu && this.lastElu ? performance.eventLoopUtilization(elu, this.lastElu).utilization : null;
73
149
  this.lastAt = now;
74
150
  this.lastCpu = process.cpuUsage();
151
+ this.lastElu = elu;
75
152
  const memory = process.memoryUsage();
153
+ const gc = this.gc.take();
154
+ const traffic = this.requests ? this.requests.take() : null;
76
155
  const sample = {
77
156
  at: Date.now(),
78
157
  rss: memory.rss,
@@ -82,7 +161,16 @@ export class RuntimeSampler {
82
161
  cpu: Math.round(((cpu.user + cpu.system) / 1000 / elapsed) * 1000) / 10,
83
162
  loopP50: delay ? delay.p50 : null,
84
163
  loopP99: delay ? delay.p99 : null,
85
- loopMax: delay ? delay.max : null
164
+ loopMax: delay ? delay.max : null,
165
+ arrayBuffers: Number.isFinite(memory.arrayBuffers) ? memory.arrayBuffers : null,
166
+ heapLimit: heapLimit(),
167
+ elu: Number.isFinite(busy) ? Math.round(busy * 1000) / 10 : null,
168
+ gcCount: gc ? gc.count : null,
169
+ gcMs: gc ? gc.ms : null,
170
+ gcMax: gc ? gc.max : null,
171
+ handles: openHandles(),
172
+ requests: traffic ? traffic.finished : null,
173
+ inFlight: traffic ? traffic.open : null
86
174
  };
87
175
  this.onSample(sample);
88
176
  return sample;
@@ -91,3 +179,29 @@ export class RuntimeSampler {
91
179
  }
92
180
  }
93
181
  }
182
+
183
+ // How busy the loop has been since the process started (Node.js 14.10 and later).
184
+ function loopUtilization() {
185
+ return typeof performance.eventLoopUtilization === 'function' ? performance.eventLoopUtilization() : null;
186
+ }
187
+
188
+ function heapLimit() {
189
+ try {
190
+ return v8.getHeapStatistics().heap_size_limit;
191
+ } catch (error) {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ // Sockets, timers, files and other resources keeping the loop alive (Node.js 17 and later).
197
+ function openHandles() {
198
+ try {
199
+ return typeof process.getActiveResourcesInfo === 'function' ? process.getActiveResourcesInfo().length : null;
200
+ } catch (error) {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function round(value) {
206
+ return Math.round(value * 10) / 10;
207
+ }
@@ -37,7 +37,7 @@ export const TIMELINE_DEFAULTS = Object.freeze({
37
37
  });
38
38
 
39
39
  // Spans listed per request, by type.
40
- const SPAN_CAPS = Object.freeze({ query: 500, http: 100, log: 200, measure: 100, exception: 10, block: 20 });
40
+ const SPAN_CAPS = Object.freeze({ query: 500, http: 100, log: 200, measure: 100, exception: 10, block: 20, dump: 50 });
41
41
  const MAX_STATEMENTS = 200;
42
42
  // Executions per statement whose parameters are kept.
43
43
  const PARAMS_PER_STATEMENT = 20;
@@ -121,7 +121,7 @@ export class Timeline {
121
121
  this.origin = origin;
122
122
  this.closed = false;
123
123
  this.spans = [];
124
- this.listed = { query: 0, http: 0, log: 0, measure: 0, exception: 0, block: 0 };
124
+ this.listed = { query: 0, http: 0, log: 0, measure: 0, exception: 0, block: 0, dump: 0 };
125
125
  this.dropped = 0;
126
126
  this.statements = [];
127
127
  this.byFingerprint = new Map();
@@ -263,7 +263,7 @@ export class Timeline {
263
263
  *
264
264
  * @param {'debug'|'info'|'warn'|'error'} level
265
265
  * @param {string} message - Formatted as it was written
266
- * @param {'console'|'pino'|'winston'} [source='console']
266
+ * @param {'console'|'pino'|'winston'|'nest'} [source='console']
267
267
  * @returns {void}
268
268
  */
269
269
  log(level, message, source = 'console') {
@@ -282,6 +282,27 @@ export class Timeline {
282
282
  }
283
283
  }
284
284
 
285
+ /**
286
+ * Records a dump made during the request (local dashboard only): its label and a preview of the
287
+ * value; the Dumps page has the value itself.
288
+ *
289
+ * @param {{ id: string, label: string|null, summary: { preview: string } }} record - A DumpRecord
290
+ * @returns {void}
291
+ */
292
+ dump(record) {
293
+ if (this.closed) return;
294
+ try {
295
+ if (this.listed.dump < SPAN_CAPS.dump) {
296
+ this.listed.dump++;
297
+ this.spans.push({ type: 'dump', start: this._offset(performance.now()), id: record.id, label: record.label, preview: cut(record.summary.preview, MAX_LOG) });
298
+ } else {
299
+ this.dropped++;
300
+ }
301
+ } catch (error) {
302
+ // The dump is still on the Dumps page.
303
+ }
304
+ }
305
+
285
306
  /**
286
307
  * Records an exception recorded during the request.
287
308
  *
@@ -13,6 +13,7 @@
13
13
  import { LOG_PREFIX } from '../config.js';
14
14
  import { ExceptionRecord } from '../exceptions/exception-record.js';
15
15
  import { RequestContext } from '../exceptions/request-context.js';
16
+ import { DumpRecord } from '../dumps/dump-record.js';
16
17
  import { JobRunRecord } from '../jobs/job-run-record.js';
17
18
  import { LogRecord } from '../logs/log-record.js';
18
19
  import { RequestRecord } from '../record.js';
@@ -170,13 +171,15 @@ export class BatchTransport {
170
171
  const exceptions = [];
171
172
  const jobs = [];
172
173
  const logs = [];
174
+ const dumps = [];
173
175
  for (const record of records) {
174
176
  if (record instanceof ExceptionRecord) exceptions.push(record);
175
177
  else if (record instanceof JobRunRecord) jobs.push(record);
176
178
  else if (record instanceof LogRecord) logs.push(record);
179
+ else if (record instanceof DumpRecord) dumps.push(record);
177
180
  else requests.push(record);
178
181
  }
179
- return { requests, exceptions, jobs, logs };
182
+ return { requests, exceptions, jobs, logs, dumps };
180
183
  }
181
184
 
182
185
  /**
@@ -16,10 +16,12 @@ import { settings } from '../config.js';
16
16
  import { ApiKey, Sealer, SyncSealer } from '../crypto.js';
17
17
  import { RequestContext } from '../exceptions/request-context.js';
18
18
  import { ExceptionRecord } from '../exceptions/exception-record.js';
19
+ import { DumpRecord } from '../dumps/dump-record.js';
19
20
  import { JobRunRecord } from '../jobs/job-run-record.js';
20
21
  import { LogRecord } from '../logs/log-record.js';
21
22
  import { RequestRecord } from '../record.js';
22
23
  import { Spool } from '../spool.js';
24
+ import { RuleMatcher } from '../alerts/rule-matcher.js';
23
25
  import { BatchTransport } from './batch.js';
24
26
 
25
27
  const FLUSH_INTERVAL_MS = 1000;
@@ -86,7 +88,7 @@ export class CloudTransport extends BatchTransport {
86
88
  constructor({ apiKey, environment, release = null, version }) {
87
89
  const key = ApiKey.parse(apiKey);
88
90
  if (!key) {
89
- throw new Error('PEEKINSIDE_API_KEY is not a valid PeekInside API key');
91
+ throw new Error(ApiKey.problem(apiKey));
90
92
  }
91
93
  super({
92
94
  environment: RequestRecord.normalizeEnvironment(environment, 'production'),
@@ -121,12 +123,25 @@ export class CloudTransport extends BatchTransport {
121
123
 
122
124
  this._resendTimer = null;
123
125
  this._resending = null;
126
+ // Alert rules on sealed fields, checked here before records are sealed (started with the first batch).
127
+ this.rules = new RuleMatcher({ fetchRules: () => this._fetchRules() });
124
128
  // Set when records that didn't fit in the last POST wait for the next one.
125
129
  this._more = false;
126
130
  // Batches spooled by an earlier run that crashed.
127
131
  this._scheduleResend(SPOOL_RESEND_DELAY_MS);
128
132
  }
129
133
 
134
+ /**
135
+ * Queues a record for sending. Dumps are for the local dashboard: PeekInside Cloud never gets them.
136
+ *
137
+ * @param {Object} record
138
+ * @returns {void}
139
+ */
140
+ enqueueRecord(record) {
141
+ if (record instanceof DumpRecord) return;
142
+ super.enqueueRecord(record);
143
+ }
144
+
130
145
  /**
131
146
  * Stops the background timer and the flush at exit. Sending itself stays possible: drain()
132
147
  * still sends what is queued.
@@ -137,6 +152,7 @@ export class CloudTransport extends BatchTransport {
137
152
  clearInterval(this.timer);
138
153
  clearTimeout(this._resendTimer);
139
154
  this._resendTimer = null;
155
+ this.rules.stop();
140
156
  super.stop();
141
157
  }
142
158
 
@@ -189,6 +205,7 @@ export class CloudTransport extends BatchTransport {
189
205
  // even a large one, or it would block the queue.
190
206
  async _pack(batch, sealer) {
191
207
  const lists = { events: [], exceptions: [], jobs: [], logs: [] };
208
+ const alerts = [];
192
209
  let size = 32;
193
210
  let count = 0;
194
211
  for (const record of batch) {
@@ -198,8 +215,10 @@ export class CloudTransport extends BatchTransport {
198
215
  size += length;
199
216
  count++;
200
217
  lists[listFor(record)].push(sealed);
218
+ // Which alert rules it met on what only this server can read: ids, never the content.
219
+ alerts.push(...this.rules.matches(record));
201
220
  }
202
- return { ...lists, rest: batch.slice(count) };
221
+ return { ...lists, alerts, rest: batch.slice(count) };
203
222
  }
204
223
 
205
224
  // Records that didn't fit go back to the front of the queue, in order, and go out right away.
@@ -213,11 +232,12 @@ export class CloudTransport extends BatchTransport {
213
232
  async _deliver(batch) {
214
233
  // A fresh ephemeral key per batch: one key agreement, a new IV per message.
215
234
  const sealer = await Sealer.create(this.apiKey.publicKey, 'event');
216
- const { events, exceptions, jobs, logs, rest } = await this._pack(batch, sealer);
235
+ const { events, exceptions, jobs, logs, alerts, rest } = await this._pack(batch, sealer);
217
236
  const body = { events };
218
237
  if (exceptions.length) body.exceptions = exceptions;
219
238
  if (jobs.length) body.jobs = jobs;
220
239
  if (logs.length) body.logs = logs;
240
+ if (alerts.length) body.alerts = alerts;
221
241
  const samples = this.takeSamples();
222
242
  if (samples.length) {
223
243
  body.instance = { ...this.instance, environment: this.environment, release: this.release };
@@ -234,6 +254,8 @@ export class CloudTransport extends BatchTransport {
234
254
  const sent = batch.length - rest.length;
235
255
 
236
256
  if (response.ok) {
257
+ // PeekInside Cloud answers: from now on, the alert rules it holds are checked here too.
258
+ this.rules.start();
237
259
  this._requeue(rest);
238
260
  return true;
239
261
  }
@@ -287,6 +309,15 @@ export class CloudTransport extends BatchTransport {
287
309
  console.error(line);
288
310
  }
289
311
 
312
+ // The project's alert rules on sealed fields, from beside the ingest endpoint.
313
+ async _fetchRules() {
314
+ if (this.exiting) throw new Error('exiting');
315
+ // Never after a redirect: the API key goes only where batches go (see _post).
316
+ const response = await fetch(`${this.endpoint}/alert-rules`, { headers: this._headers(), redirect: 'manual', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
317
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
318
+ return response.json();
319
+ }
320
+
290
321
  _headers() {
291
322
  return {
292
323
  'content-type': 'application/json',
@@ -182,14 +182,15 @@ export class LocalTransport extends BatchTransport {
182
182
  const records = fatal ? [...this.sending, ...this.queue.splice(0)] : [];
183
183
  for (const exchange of exchanges) records.push(RequestRecord.fromExchange(exchange, this.environment, this.release));
184
184
  records.push(...exceptions);
185
- const { requests, exceptions: errors, jobs, logs } = BatchTransport.split(records);
185
+ const { requests, exceptions: errors, jobs, logs, dumps } = BatchTransport.split(records);
186
186
  Spool.local(settings.dataDir(), this.project.id).write({
187
187
  project: this.project,
188
188
  instance: this.instance,
189
189
  requests: requests.slice(-MAX_SPOOLED_REQUESTS),
190
190
  exceptions: errors.slice(-MAX_SPOOLED_EXCEPTIONS),
191
191
  jobs: jobs.slice(-MAX_SPOOLED_EXCEPTIONS),
192
- logs: logs.slice(-MAX_SPOOLED_REQUESTS)
192
+ logs: logs.slice(-MAX_SPOOLED_REQUESTS),
193
+ dumps: dumps.slice(-MAX_SPOOLED_EXCEPTIONS)
193
194
  });
194
195
  } catch (error) {
195
196
  // A data folder that can't be written loses the records, never the app.
@@ -207,13 +208,14 @@ export class LocalTransport extends BatchTransport {
207
208
  }
208
209
  const blocked = this.blocked.take();
209
210
  const samples = this.takeSamples();
210
- const { requests, exceptions, jobs, logs } = BatchTransport.split(batch);
211
+ const { requests, exceptions, jobs, logs, dumps } = BatchTransport.split(batch);
211
212
  try {
212
213
  const answer = await this.hub.request('POST', '/api/ingest', {
213
- body: { project: this.project, instance: this.instance, requests, exceptions, jobs, logs, samples, blocked: blocked.map(([, entry]) => entry) }
214
+ body: { project: this.project, instance: this.instance, requests, exceptions, jobs, logs, dumps, samples, blocked: blocked.map(([, entry]) => entry) }
214
215
  });
215
216
  if (exceptions.length && answer.acceptedExceptions === undefined) this._noteOutdatedHub();
216
217
  else if ((jobs.length || logs.length) && answer.acceptedJobs === undefined) this._noteOutdatedHub();
218
+ else if (dumps.length && answer.acceptedDumps === undefined) this._noteOutdatedHub();
217
219
  return true;
218
220
  } catch (error) {
219
221
  if (error.code === 'NOT_HUB') {
@@ -238,7 +240,7 @@ export class LocalTransport extends BatchTransport {
238
240
  }
239
241
 
240
242
  // A hub started before this version keeps running until it is restarted, and ignores what it
241
- // doesn't know (exceptions, job runs, log lines).
243
+ // doesn't know (exceptions, job runs, log lines, dumps).
242
244
  _noteOutdatedHub() {
243
245
  if (this.outdatedHubNoted) return;
244
246
  this.outdatedHubNoted = true;
package/src/user.js CHANGED
@@ -9,12 +9,14 @@
9
9
  const MAX_ID = 128;
10
10
  const MAX_EMAIL = 254;
11
11
  const MAX_NAME = 128;
12
+ const MAX_PHONE = 32;
12
13
 
13
14
  /**
14
15
  * @typedef {Object} User
15
16
  * @property {string|null} id
16
17
  * @property {string|null} email
17
18
  * @property {string|null} name
19
+ * @property {string|null} phone
18
20
  */
19
21
 
20
22
  /**
@@ -22,14 +24,15 @@ const MAX_NAME = 128;
22
24
  */
23
25
  export class UserInfo {
24
26
  /**
25
- * Accepts `{ id, email, name }` (also `_id`, `userId` and `username`, as user objects of common
26
- * libraries have them), or just an id. Anything else is no user.
27
+ * Accepts `{ id, email, name, phone }` (also `_id`, `userId`, `username`, `phoneNumber`,
28
+ * `phone_number` and `mobile`, as user objects of common libraries and apps have them), or just
29
+ * an id. Anything else is no user.
27
30
  *
28
31
  * @param {*} user
29
32
  * @returns {User|null} null when it names nobody
30
33
  *
31
34
  * @example
32
- * UserInfo.normalize({ id: 42, email: 'ada@example.com' }) // { id: '42', email: 'ada@example.com', name: null }
35
+ * UserInfo.normalize({ id: 42, phone: '+91 98765 43210' }) // { id: '42', email: null, name: null, phone: '+91 98765 43210' }
33
36
  */
34
37
  static normalize(user) {
35
38
  if (user === null || user === undefined) return null;
@@ -37,25 +40,27 @@ export class UserInfo {
37
40
  let id;
38
41
  let email;
39
42
  let name;
43
+ let phone;
40
44
  try {
41
45
  id = firstDefined(user.id, user._id, user.userId);
42
46
  email = user.email;
43
47
  name = firstDefined(user.name, user.username);
48
+ phone = firstDefined(user.phone, user.phoneNumber, user.phone_number, user.mobile);
44
49
  } catch (error) {
45
50
  return null;
46
51
  }
47
- const clean = { id: identifier(id), email: label(email, MAX_EMAIL), name: label(name, MAX_NAME) };
48
- return clean.id || clean.email || clean.name ? clean : null;
52
+ const clean = { id: identifier(id), email: label(email, MAX_EMAIL), name: label(name, MAX_NAME), phone: phoneNumber(phone) };
53
+ return clean.id || clean.email || clean.name || clean.phone ? clean : null;
49
54
  }
50
55
 
51
56
  /**
52
- * One string that tells users apart: the id, else the email, else the name.
57
+ * One string that tells users apart: the id, else the email, else the phone, else the name.
53
58
  *
54
59
  * @param {User|null} user
55
60
  * @returns {string|null}
56
61
  */
57
62
  static key(user) {
58
- return user ? user.id || user.email || user.name || null : null;
63
+ return user ? user.id || user.email || user.phone || user.name || null : null;
59
64
  }
60
65
  }
61
66
 
@@ -76,6 +81,12 @@ function identifier(value) {
76
81
  return null;
77
82
  }
78
83
 
84
+ // As the app wrote it ("+91 98765 43210"); numbers (and bigints) as their digits.
85
+ function phoneNumber(value) {
86
+ if (typeof value === 'number' || typeof value === 'bigint') return label(String(value), MAX_PHONE);
87
+ return label(value, MAX_PHONE);
88
+ }
89
+
79
90
  function label(value, max) {
80
91
  if (typeof value !== 'string') return null;
81
92
  const clean = value.trim().slice(0, max);