peekinside 1.2.3 → 1.2.5

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 CHANGED
@@ -5,6 +5,7 @@ Debug Node.js from the inside: every error with the request that set it off, the
5
5
  [![npm](https://img.shields.io/npm/v/peekinside?color=18b69b)](https://www.npmjs.com/package/peekinside)
6
6
  ![License](https://img.shields.io/badge/license-MIT-18b69b)
7
7
  ![Node](https://img.shields.io/badge/node-%3E%3D18-18b69b)
8
+ [![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000)](https://buymeacoffee.com/thejesbin)
8
9
 
9
10
  ## Install
10
11
 
@@ -38,9 +39,16 @@ Everything else is in the docs: **[peekinside.dev/docs](https://peekinside.dev/d
38
39
  - [Logs](https://peekinside.dev/docs/logs)
39
40
  - [Jobs and schedules](https://peekinside.dev/docs/jobs)
40
41
  - [Runtime](https://peekinside.dev/docs/runtime)
42
+ - [Alerts](https://peekinside.dev/docs/alerts)
41
43
  - [Configuration](https://peekinside.dev/docs/configuration)
42
44
  - [API](https://peekinside.dev/docs/api)
43
45
 
46
+ ## Support
47
+
48
+ If PeekInside saves you a debugging session, you can buy me a coffee:
49
+
50
+ [![Buy me a coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=thejesbin&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://buymeacoffee.com/thejesbin)
51
+
44
52
  ## License
45
53
 
46
54
  MIT
package/index.d.ts CHANGED
@@ -208,6 +208,8 @@ export interface PeekInsideUser {
208
208
  id?: string | number;
209
209
  email?: string;
210
210
  name?: string;
211
+ /** As your app writes it, such as "+91 98765 43210". */
212
+ phone?: string | number;
211
213
  }
212
214
 
213
215
  /** Options that can still change after the instance is created (see `attachTo`). */
@@ -315,7 +317,7 @@ declare class PeekInside {
315
317
  *
316
318
  * @example
317
319
  * app.use((req, res, next) => {
318
- * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email });
320
+ * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email, name: req.user.name, phone: req.user.phone });
319
321
  * next();
320
322
  * });
321
323
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peekinside",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Debug Node.js from the inside: errors with the requests, queries, logs and jobs behind them. A free local dashboard, and encrypted monitoring for production",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
package/src/PeekInside.js CHANGED
@@ -42,6 +42,7 @@ import { JobRunner } from './jobs/job-runner.js';
42
42
  import { LogCapture } from './logs/log-capture.js';
43
43
  import { LoopMonitor } from './runtime/loop-monitor.js';
44
44
  import { RuntimeSampler } from './runtime/sampler.js';
45
+ import { RequestCounter } from './runtime/request-counter.js';
45
46
  import { UserInfo } from './user.js';
46
47
  import { CloudTransport } from './transport/cloud.js';
47
48
  import { LocalTransport } from './transport/local.js';
@@ -159,6 +160,8 @@ class PeekInside {
159
160
  // Whether this instance uses the event-loop monitor, and its process sampler.
160
161
  this._loopMonitor = false;
161
162
  this._sampler = null;
163
+ // The requests this instance serves, counted for its process samples.
164
+ this._requests = null;
162
165
  // Log lines for the Logs page, and job runs.
163
166
  this._logs = null;
164
167
  this._jobs = null;
@@ -218,9 +221,11 @@ class PeekInside {
218
221
  }
219
222
  if (this.options.runtime !== false) {
220
223
  const transport = this._transport;
224
+ this._requests = new RequestCounter();
221
225
  this._sampler = new RuntimeSampler({
222
226
  intervalMs: this._cloud ? 60_000 : 10_000,
223
227
  monitor: LoopMonitor.shared,
228
+ requests: this._requests,
224
229
  onSample: (sample) => transport.addSample(sample)
225
230
  }).start();
226
231
  }
@@ -427,13 +432,14 @@ class PeekInside {
427
432
  * customer ran into. In PeekInside Cloud the user is encrypted with the rest of the request.
428
433
  * Call it once the user is known, such as in your authentication middleware.
429
434
  *
430
- * @param {{ id?: string|number, email?: string, name?: string }|string|number|null} user - The
431
- * user, or just their id; null forgets it
435
+ * @param {{ id?: string|number, email?: string, name?: string, phone?: string|number }|string|number|null} user -
436
+ * The user, or just their id; null forgets it. The phone is kept as written (phoneNumber,
437
+ * phone_number and mobile work too).
432
438
  * @returns {boolean} Whether a request was running to attach it to
433
439
  *
434
440
  * @example
435
441
  * app.use((req, res, next) => {
436
- * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email });
442
+ * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email, name: req.user.name, phone: req.user.phone });
437
443
  * next();
438
444
  * });
439
445
  */
@@ -683,6 +689,7 @@ class PeekInside {
683
689
 
684
690
  _track(req, res, target, integration) {
685
691
  let context = null;
692
+ const requests = this._requests;
686
693
  const recorder = new ExchangeRecorder(req, res, target, {
687
694
  maxBodySize: () => this.options.maxBodySize,
688
695
  onExchange: (exchange) => {
@@ -698,6 +705,19 @@ class PeekInside {
698
705
  });
699
706
  context = new RequestContext({ capture: this._exceptions, req, recorder, target, integration });
700
707
  recorder.start();
708
+ if (requests) {
709
+ // Counted off the response itself, so a request ends once whatever happens to its record.
710
+ requests.started();
711
+ let open = true;
712
+ const end = () => {
713
+ if (open) {
714
+ open = false;
715
+ requests.ended();
716
+ }
717
+ };
718
+ res.once('finish', end);
719
+ res.once('close', end);
720
+ }
701
721
  if (this._timeline) {
702
722
  context.timeline = new Timeline(this._timeline, recorder.perfStart);
703
723
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The parts of a project's alert rules that only the app's server can check: conditions on a
3
+ * request's path, user and response, and on an exception's or a log line's message, which reach
4
+ * PeekInside Cloud sealed. The matcher fetches them now and then, checks each record before it is
5
+ * sealed, and the transport sends which records met which rule with the batch: never what they
6
+ * hold. Everything else about a rule (a status, a duration, an environment) is checked by
7
+ * PeekInside Cloud itself.
8
+ */
9
+
10
+ import { RequestContext } from '../exceptions/request-context.js';
11
+
12
+ // Fetched again this often, the first time this long after the transport first delivered.
13
+ const REFRESH_MS = 60_000;
14
+ const FIRST_FETCH_MS = 1000;
15
+ // The most of a response body checked for a "contains" condition.
16
+ const MAX_BODY = 64 * 1024;
17
+ const RULE_ID = /^rl[0-9a-z]{12}$/;
18
+
19
+ /**
20
+ * @typedef {Object} SealedRule
21
+ * @property {string} id - The rule's id
22
+ * @property {'requests'|'exceptions'|'logs'} source
23
+ * @property {{ field: string, op: 'is'|'is-not'|'contains'|'matches', value: string }[]} conditions
24
+ */
25
+
26
+ export class RuleMatcher {
27
+ /**
28
+ * @param {Object} options
29
+ * @param {() => Promise<{ rules: SealedRule[] }>} options.fetchRules - Asks PeekInside Cloud for them
30
+ * @param {number} [options.refreshMs]
31
+ */
32
+ constructor({ fetchRules, refreshMs = REFRESH_MS }) {
33
+ this.fetchRules = fetchRules;
34
+ this.refreshMs = refreshMs;
35
+ /** @type {SealedRule[]} */
36
+ this.rules = [];
37
+ this.timer = null;
38
+ this.first = null;
39
+ }
40
+
41
+ /**
42
+ * Fetches the rules shortly, then keeps them current. Its timers never keep the process alive:
43
+ * a script that ends right away never waits for them.
44
+ *
45
+ * @returns {void}
46
+ */
47
+ start() {
48
+ if (this.timer || this.first) return;
49
+ this.first = RequestContext.outside(() =>
50
+ setTimeout(() => {
51
+ void this.refresh();
52
+ this.timer = RequestContext.outside(() => setInterval(() => void this.refresh(), this.refreshMs));
53
+ if (typeof this.timer.unref === 'function') this.timer.unref();
54
+ }, FIRST_FETCH_MS)
55
+ );
56
+ if (typeof this.first.unref === 'function') this.first.unref();
57
+ }
58
+
59
+ stop() {
60
+ clearTimeout(this.first);
61
+ clearInterval(this.timer);
62
+ this.first = null;
63
+ this.timer = null;
64
+ }
65
+
66
+ /**
67
+ * @returns {Promise<void>} Never rejects: without rules, nothing is reported
68
+ */
69
+ async refresh() {
70
+ try {
71
+ const answer = await RequestContext.outside(() => this.fetchRules());
72
+ this.rules = Array.isArray(answer && answer.rules) ? answer.rules.filter(valid) : [];
73
+ } catch (error) {
74
+ // Kept as they were: PeekInside Cloud may be briefly unreachable.
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The rules a record meets on its sealed fields.
80
+ *
81
+ * @param {Object} record - A RequestRecord, ExceptionRecord or LogRecord, before it is sealed
82
+ * @returns {{ rule: string, id: string }[]}
83
+ */
84
+ matches(record) {
85
+ if (!this.rules.length || !record || typeof record.id !== 'string') return [];
86
+ const source = sourceOf(record);
87
+ if (!source) return [];
88
+ const found = [];
89
+ for (const rule of this.rules) {
90
+ if (rule.source !== source) continue;
91
+ if (rule.conditions.every((condition) => meets(condition, valuesOf(record, condition.field)))) found.push({ rule: rule.id, id: record.id });
92
+ }
93
+ return found;
94
+ }
95
+ }
96
+
97
+ function valid(rule) {
98
+ return (
99
+ rule &&
100
+ RULE_ID.test(rule.id) &&
101
+ ['requests', 'exceptions', 'logs'].includes(rule.source) &&
102
+ Array.isArray(rule.conditions) &&
103
+ rule.conditions.length > 0 &&
104
+ rule.conditions.every((condition) => condition && typeof condition.field === 'string' && typeof condition.value === 'string')
105
+ );
106
+ }
107
+
108
+ function sourceOf(record) {
109
+ const name = record.constructor && record.constructor.name;
110
+ if (name === 'RequestRecord') return 'requests';
111
+ if (name === 'ExceptionRecord') return 'exceptions';
112
+ if (name === 'LogRecord') return 'logs';
113
+ return null;
114
+ }
115
+
116
+ // The values a condition looks at (a user has several: its id, email and phone).
117
+ function valuesOf(record, field) {
118
+ const summary = record.summary || {};
119
+ switch (field) {
120
+ case 'path':
121
+ return [summary.path];
122
+ case 'user': {
123
+ const user = summary.user || {};
124
+ return [user.id, user.email, user.phone].filter((value) => value !== undefined && value !== null).map(String);
125
+ }
126
+ case 'response': {
127
+ const body = record.detail && record.detail.response ? record.detail.response.body : null;
128
+ const text = typeof body === 'string' ? body : body && typeof body.text === 'string' ? body.text : '';
129
+ return [text.slice(0, MAX_BODY)];
130
+ }
131
+ case 'name':
132
+ return [summary.name];
133
+ case 'message':
134
+ return [summary.message];
135
+ default:
136
+ return [];
137
+ }
138
+ }
139
+
140
+ // Text compares without case; "matches" takes * as anything. A condition is met by any of the values.
141
+ function meets(condition, values) {
142
+ const wanted = String(condition.value).toLowerCase();
143
+ const pattern = condition.op === 'matches' ? new RegExp(`^${wanted.split('*').map(escape).join('.*')}$`) : null;
144
+ const met = values.some((value) => {
145
+ if (typeof value !== 'string') return false;
146
+ const text = value.toLowerCase();
147
+ if (condition.op === 'is') return text === wanted;
148
+ if (condition.op === 'is-not') return text !== wanted;
149
+ if (condition.op === 'contains') return text.includes(wanted);
150
+ if (condition.op === 'matches') return pattern.test(text);
151
+ return false;
152
+ });
153
+ return condition.op === 'is-not' ? values.length === 0 || met : met;
154
+ }
155
+
156
+ function escape(text) {
157
+ return text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
158
+ }
package/src/crypto.js CHANGED
@@ -177,13 +177,37 @@ export class ApiKey {
177
177
  }
178
178
 
179
179
  /**
180
- * @param {string} apiKey
180
+ * @param {string} apiKey - Surrounding spaces and quotes are ignored (env files that keep them)
181
181
  * @returns {ApiKey|null} The key's parts, or null when it isn't a well-formed v1 key
182
182
  */
183
183
  static parse(apiKey) {
184
- const [version, projectId, secret, publicKey, ...rest] = String(apiKey).trim().split('.');
184
+ const [version, projectId, secret, publicKey, ...rest] = ApiKey.unquote(apiKey).split('.');
185
185
  if (version !== 'pik1' || rest.length) return null;
186
186
  if (!PROJECT_ID.test(projectId) || !SECRET.test(secret) || !PUBLIC_KEY.test(publicKey)) return null;
187
187
  return new ApiKey({ projectId, secret, publicKey });
188
188
  }
189
+
190
+ /**
191
+ * What's wrong with a value that isn't an API key, in words for the app's log. Never repeats the
192
+ * value: it may hold a secret.
193
+ *
194
+ * @param {string} value
195
+ * @returns {string} A sentence without its final period
196
+ */
197
+ static problem(value) {
198
+ const text = ApiKey.unquote(value);
199
+ if (PUBLIC_KEY.test(text) && Buffer.from(text, 'base64url')[0] === 4) {
200
+ return 'PEEKINSIDE_API_KEY holds your project\'s public key, not an API key. API keys start with "pik1.": create one in PeekInside Cloud, on the project\'s Settings page under API keys';
201
+ }
202
+ if (!text.startsWith('pik1.')) {
203
+ return 'PEEKINSIDE_API_KEY is not a PeekInside API key (those start with "pik1."). Create one in PeekInside Cloud, on the project\'s Settings page under API keys';
204
+ }
205
+ return 'PEEKINSIDE_API_KEY looks cut off or changed: copy the whole key, from "pik1." to its end';
206
+ }
207
+
208
+ // Without the spaces and one pair of matching quotes around it.
209
+ static unquote(value) {
210
+ const text = String(value).trim();
211
+ return /^(["'`]).*\1$/s.test(text) && text.length > 1 ? text.slice(1, -1).trim() : text;
212
+ }
189
213
  }
@@ -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
  *
@@ -40,6 +40,18 @@ const RELEASE = /^[A-Za-z0-9._:@/+-]{1,64}$/;
40
40
  const MAX_SAMPLES = 120;
41
41
  const SAMPLE_BYTES = ['rss', 'heapUsed', 'heapTotal', 'external'];
42
42
  const SAMPLE_DELAYS = ['loopP50', 'loopP99', 'loopMax'];
43
+ // Later additions, each optional (older SDKs don't send them): the field, and its largest value.
44
+ const SAMPLE_EXTRAS = [
45
+ ['arrayBuffers', 1e13],
46
+ ['heapLimit', 1e13],
47
+ ['elu', 100],
48
+ ['gcCount', 1e7],
49
+ ['gcMs', 1e7],
50
+ ['gcMax', 1e7],
51
+ ['handles', 1e7],
52
+ ['requests', 1e9],
53
+ ['inFlight', 1e7]
54
+ ];
43
55
 
44
56
  /**
45
57
  * A validated ingest batch: `{ project, instance, requests, exceptions, samples, blocked }`.
@@ -307,6 +319,10 @@ function readSamples(value) {
307
319
  const number = sample[key];
308
320
  clean[key] = Number.isFinite(number) && number >= 0 && number <= 1e7 ? number : null;
309
321
  }
322
+ for (const [key, max] of SAMPLE_EXTRAS) {
323
+ const number = sample[key];
324
+ clean[key] = Number.isFinite(number) && number >= 0 && number <= max ? number : null;
325
+ }
310
326
  return [clean];
311
327
  });
312
328
  }
@@ -324,8 +340,13 @@ function readLocation(value) {
324
340
  // Who a request or exception was for: short strings only, null when it names nobody.
325
341
  function readUser(value) {
326
342
  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;
343
+ const user = {
344
+ id: text(value.id, 128) || null,
345
+ email: text(value.email, 254) || null,
346
+ name: text(value.name, 128) || null,
347
+ phone: text(value.phone, 32) || null
348
+ };
349
+ return user.id || user.email || user.name || user.phone ? user : null;
329
350
  }
330
351
 
331
352
  function readRequestLine(value) {
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 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
@@ -48,7 +48,7 @@ const MAX_PAGE = 1000;
48
48
  // /api/projects/:id, then optionally a collection and an item.
49
49
  const PROJECT_ROUTE = /^\/api\/projects\/([^/]+)(?:\/(requests|exceptions|exception-state|runtime|jobs|logs)(?:\/([^/]+))?)?$/;
50
50
  // 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))?\/?$/;
51
+ const SHELL_ROUTE = /^\/p\/[a-z0-9-]{1,64}(?:\/(?:exceptions|routes|queries|runtime|logs|jobs))?\/?$/;
52
52
 
53
53
  const BASE_HEADERS = {
54
54
  'X-Content-Type-Options': 'nosniff',
@@ -4,6 +4,9 @@
4
4
  * (queue workers, with the queue, the attempt and the job's data, and the schedule of repeatable
5
5
  * jobs). Each run goes through JobRunner, exactly as PeekInside.job() would run it.
6
6
  *
7
+ * A schedule is recorded with its time zone: the one given to the library, else the process's
8
+ * own, which is the one these libraries read a cron expression in without one.
9
+ *
7
10
  * Libraries that ship separate CommonJS and ES module builds (node-cron, BullMQ) are patched in
8
11
  * both: the CommonJS one right away, the ES module one as soon as it is imported here, which is
9
12
  * before any job can run.
@@ -91,7 +94,8 @@ export class JobInstrumentation {
91
94
  proto.callProcessJob = function (job) {
92
95
  const args = arguments;
93
96
  const repeat = job && job.opts && job.opts.repeat;
94
- const schedule = repeat ? repeat.pattern || repeat.cron || (Number.isFinite(repeat.every) ? `@every ${repeat.every}ms` : undefined) : undefined;
97
+ const pattern = repeat ? repeat.pattern || repeat.cron : undefined;
98
+ const schedule = repeat ? pattern || (Number.isFinite(repeat.every) ? `@every ${repeat.every}ms` : undefined) : undefined;
95
99
  return JobRunner.run(
96
100
  job && job.name && job.name !== '__default__' ? job.name : this.name,
97
101
  () => original.apply(this, args),
@@ -100,7 +104,7 @@ export class JobInstrumentation {
100
104
  attempt: job && Number.isInteger(job.attemptsMade) ? job.attemptsMade + 1 : 1,
101
105
  data: job ? job.data : undefined,
102
106
  schedule,
103
- timezone: repeat && typeof repeat.tz === 'string' ? repeat.tz : undefined,
107
+ timezone: repeat && typeof repeat.tz === 'string' && repeat.tz ? repeat.tz : pattern ? localTimeZone() : undefined,
104
108
  trigger: schedule ? 'cron' : 'queue'
105
109
  }
106
110
  );
@@ -146,7 +150,11 @@ function wrapCronTask(task) {
146
150
  const name = task.name && task.name !== task.id ? task.name : `cron ${expression || 'task'}`;
147
151
  const wrapped = function () {
148
152
  const args = arguments;
149
- return JobRunner.run(name, () => original.apply(this, args), { schedule: expression || undefined, timezone: task.timezone, trigger: 'cron' });
153
+ return JobRunner.run(name, () => original.apply(this, args), {
154
+ schedule: expression || undefined,
155
+ timezone: (typeof task.timezone === 'string' && task.timezone) || localTimeZone(),
156
+ trigger: 'cron'
157
+ });
150
158
  };
151
159
  wrapped[kWrapped] = true;
152
160
  runner.onMatch = wrapped;
@@ -159,7 +167,7 @@ function wrapCronJobCallback(job, callback) {
159
167
  const args = arguments;
160
168
  return JobRunner.run(job.name || `cron ${source || 'job'}`, () => callback.apply(this, args), {
161
169
  schedule: source || undefined,
162
- timezone: job.cronTime && typeof job.cronTime.timeZone === 'string' ? job.cronTime.timeZone : undefined,
170
+ timezone: (job.cronTime && typeof job.cronTime.timeZone === 'string' && job.cronTime.timeZone) || localTimeZone(),
163
171
  trigger: 'cron'
164
172
  });
165
173
  };
@@ -167,6 +175,15 @@ function wrapCronJobCallback(job, callback) {
167
175
  return wrapped;
168
176
  }
169
177
 
178
+ // The process's time zone (TZ, else the system's).
179
+ function localTimeZone() {
180
+ try {
181
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
182
+ } catch (error) {
183
+ return undefined;
184
+ }
185
+ }
186
+
170
187
  // The file an `import` of the package loads, when it differs from what require() loads.
171
188
  function esmEntry(packageDir) {
172
189
  try {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The requests an instance serves, for its process samples: how many are open, and how many
3
+ * finished since the last sample. Two numbers, no request data.
4
+ */
5
+ export class RequestCounter {
6
+ constructor() {
7
+ this.open = 0;
8
+ this.finished = 0;
9
+ }
10
+
11
+ /**
12
+ * A request started.
13
+ *
14
+ * @returns {void}
15
+ */
16
+ started() {
17
+ this.open++;
18
+ }
19
+
20
+ /**
21
+ * A request finished (or its connection closed first).
22
+ *
23
+ * @returns {void}
24
+ */
25
+ ended() {
26
+ this.open = Math.max(0, this.open - 1);
27
+ this.finished++;
28
+ }
29
+
30
+ /**
31
+ * The requests finished since the last call, and the ones open now.
32
+ *
33
+ * @returns {{ finished: number, open: number }}
34
+ */
35
+ take() {
36
+ const finished = this.finished;
37
+ this.finished = 0;
38
+ return { finished, open: this.open };
39
+ }
40
+ }
@@ -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
+ }
@@ -20,6 +20,7 @@ import { JobRunRecord } from '../jobs/job-run-record.js';
20
20
  import { LogRecord } from '../logs/log-record.js';
21
21
  import { RequestRecord } from '../record.js';
22
22
  import { Spool } from '../spool.js';
23
+ import { RuleMatcher } from '../alerts/rule-matcher.js';
23
24
  import { BatchTransport } from './batch.js';
24
25
 
25
26
  const FLUSH_INTERVAL_MS = 1000;
@@ -56,7 +57,16 @@ process.stdin.on('data', (chunk) => { input += chunk; });
56
57
  process.stdin.on('end', () => {
57
58
  let job;
58
59
  try { job = JSON.parse(input); } catch (error) { process.exit(1); }
59
- fetch(job.url, { method: 'POST', headers: job.headers, body: job.body, signal: AbortSignal.timeout(${CRASH_SEND_TIMEOUT_MS - 500}) })
60
+ const signal = AbortSignal.timeout(${CRASH_SEND_TIMEOUT_MS - 500});
61
+ const post = (url) => fetch(url, { method: 'POST', headers: job.headers, body: job.body, redirect: 'manual', signal });
62
+ post(job.url)
63
+ .then((response) => {
64
+ const location = response.headers.get('location');
65
+ if ((response.status !== 307 && response.status !== 308) || !location) return response;
66
+ const target = new URL(location, job.url);
67
+ const ok = job.follow.hosts.includes(target.hostname) && job.follow.protocols.includes(target.protocol) && target.pathname === job.follow.path && (target.port === '' || target.port === job.follow.port);
68
+ return ok ? post(target.href) : response;
69
+ })
60
70
  .then((response) => process.exit(response.ok ? ${SENT} : response.status === 400 || response.status === 413 ? ${REFUSED} : 2))
61
71
  .catch(() => process.exit(1));
62
72
  });
@@ -77,7 +87,7 @@ export class CloudTransport extends BatchTransport {
77
87
  constructor({ apiKey, environment, release = null, version }) {
78
88
  const key = ApiKey.parse(apiKey);
79
89
  if (!key) {
80
- throw new Error('PEEKINSIDE_API_KEY is not a valid PeekInside API key');
90
+ throw new Error(ApiKey.problem(apiKey));
81
91
  }
82
92
  super({
83
93
  environment: RequestRecord.normalizeEnvironment(environment, 'production'),
@@ -112,6 +122,8 @@ export class CloudTransport extends BatchTransport {
112
122
 
113
123
  this._resendTimer = null;
114
124
  this._resending = null;
125
+ // Alert rules on sealed fields, checked here before records are sealed (started with the first batch).
126
+ this.rules = new RuleMatcher({ fetchRules: () => this._fetchRules() });
115
127
  // Set when records that didn't fit in the last POST wait for the next one.
116
128
  this._more = false;
117
129
  // Batches spooled by an earlier run that crashed.
@@ -128,6 +140,7 @@ export class CloudTransport extends BatchTransport {
128
140
  clearInterval(this.timer);
129
141
  clearTimeout(this._resendTimer);
130
142
  this._resendTimer = null;
143
+ this.rules.stop();
131
144
  super.stop();
132
145
  }
133
146
 
@@ -180,6 +193,7 @@ export class CloudTransport extends BatchTransport {
180
193
  // even a large one, or it would block the queue.
181
194
  async _pack(batch, sealer) {
182
195
  const lists = { events: [], exceptions: [], jobs: [], logs: [] };
196
+ const alerts = [];
183
197
  let size = 32;
184
198
  let count = 0;
185
199
  for (const record of batch) {
@@ -189,8 +203,10 @@ export class CloudTransport extends BatchTransport {
189
203
  size += length;
190
204
  count++;
191
205
  lists[listFor(record)].push(sealed);
206
+ // Which alert rules it met on what only this server can read: ids, never the content.
207
+ alerts.push(...this.rules.matches(record));
192
208
  }
193
- return { ...lists, rest: batch.slice(count) };
209
+ return { ...lists, alerts, rest: batch.slice(count) };
194
210
  }
195
211
 
196
212
  // Records that didn't fit go back to the front of the queue, in order, and go out right away.
@@ -204,11 +220,12 @@ export class CloudTransport extends BatchTransport {
204
220
  async _deliver(batch) {
205
221
  // A fresh ephemeral key per batch: one key agreement, a new IV per message.
206
222
  const sealer = await Sealer.create(this.apiKey.publicKey, 'event');
207
- const { events, exceptions, jobs, logs, rest } = await this._pack(batch, sealer);
223
+ const { events, exceptions, jobs, logs, alerts, rest } = await this._pack(batch, sealer);
208
224
  const body = { events };
209
225
  if (exceptions.length) body.exceptions = exceptions;
210
226
  if (jobs.length) body.jobs = jobs;
211
227
  if (logs.length) body.logs = logs;
228
+ if (alerts.length) body.alerts = alerts;
212
229
  const samples = this.takeSamples();
213
230
  if (samples.length) {
214
231
  body.instance = { ...this.instance, environment: this.environment, release: this.release };
@@ -225,6 +242,8 @@ export class CloudTransport extends BatchTransport {
225
242
  const sent = batch.length - rest.length;
226
243
 
227
244
  if (response.ok) {
245
+ // PeekInside Cloud answers: from now on, the alert rules it holds are checked here too.
246
+ this.rules.start();
228
247
  this._requeue(rest);
229
248
  return true;
230
249
  }
@@ -278,6 +297,15 @@ export class CloudTransport extends BatchTransport {
278
297
  console.error(line);
279
298
  }
280
299
 
300
+ // The project's alert rules on sealed fields, from beside the ingest endpoint.
301
+ async _fetchRules() {
302
+ if (this.exiting) throw new Error('exiting');
303
+ // Never after a redirect: the API key goes only where batches go (see _post).
304
+ const response = await fetch(`${this.endpoint}/alert-rules`, { headers: this._headers(), redirect: 'manual', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
305
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
306
+ return response.json();
307
+ }
308
+
281
309
  _headers() {
282
310
  return {
283
311
  'content-type': 'application/json',
@@ -287,13 +315,21 @@ export class CloudTransport extends BatchTransport {
287
315
  };
288
316
  }
289
317
 
290
- _post(body) {
291
- return fetch(this.endpoint, {
318
+ // Posts to the endpoint. A redirect to the site's twin (see redirectTarget) is followed here,
319
+ // with the API key, and remembered; fetch() would follow it but drop the key.
320
+ async _post(body) {
321
+ const post = (url) => fetch(url, {
292
322
  method: 'POST',
293
323
  headers: this._headers(),
294
324
  body,
325
+ redirect: 'manual',
295
326
  signal: AbortSignal.timeout(this.exiting ? EXIT_TIMEOUT_MS : REQUEST_TIMEOUT_MS)
296
327
  });
328
+ const response = await post(this.endpoint);
329
+ const moved = redirectTarget(response, this.endpoint);
330
+ if (!moved) return response;
331
+ this.endpoint = moved;
332
+ return post(moved);
297
333
  }
298
334
 
299
335
  // The newest queued records, sealed, within the crash budget; they leave the queue.
@@ -314,7 +350,7 @@ export class CloudTransport extends BatchTransport {
314
350
  // Posts from a child process and waits for it: the only way to send over the network
315
351
  // synchronously. The secret travels on stdin, never on the command line.
316
352
  _sendNow(body) {
317
- const input = JSON.stringify({ url: this.endpoint, headers: this._headers(), body });
353
+ const input = JSON.stringify({ url: this.endpoint, headers: this._headers(), body, follow: followRule(this.endpoint) });
318
354
  const env = process.platform === 'win32' && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {};
319
355
  const result = spawnSync(process.execPath, ['-e', SENDER], {
320
356
  input,
@@ -395,3 +431,39 @@ function listFor(record) {
395
431
  if (record instanceof LogRecord) return 'logs';
396
432
  return 'events';
397
433
  }
434
+
435
+ /**
436
+ * Where a 307 or 308 answer from PeekInside Cloud moves its endpoint, when the API key may follow
437
+ * it there, or null. Hosting often sends a site's bare domain to its www name, or back (Vercel
438
+ * does by default), and fetch() drops the Authorization header on such a redirect, so it is
439
+ * followed by hand, and only to the same path on the same host or its www twin, never downgrading
440
+ * https. Anywhere else, the answer stands (and the batch is retried later).
441
+ *
442
+ * @param {Response} response
443
+ * @param {string} from - The URL that answered
444
+ * @returns {string|null}
445
+ */
446
+ export function redirectTarget(response, from) {
447
+ if (response.status !== 307 && response.status !== 308) return null;
448
+ const location = response.headers.get('location');
449
+ if (!location) return null;
450
+ let target;
451
+ try {
452
+ target = new URL(location, from);
453
+ } catch (error) {
454
+ return null;
455
+ }
456
+ const rule = followRule(from);
457
+ const ok = rule.hosts.includes(target.hostname) && rule.protocols.includes(target.protocol) && target.pathname === rule.path && (target.port === '' || target.port === rule.port);
458
+ return ok ? target.href : null;
459
+ }
460
+
461
+ // What redirectTarget accepts, as data, so the crash sender's child process applies the same rule.
462
+ function followRule(from) {
463
+ const url = new URL(from);
464
+ const host = url.hostname;
465
+ const named = host.includes('.') && !host.startsWith('[') && !/^[\d.]+$/.test(host) && host !== 'localhost' && !host.endsWith('.localhost');
466
+ const hosts = [host];
467
+ if (named) hosts.push(host.startsWith('www.') ? host.slice(4) : `www.${host}`);
468
+ return { hosts, protocols: url.protocol === 'http:' ? ['http:', 'https:'] : [url.protocol], path: url.pathname, port: url.port };
469
+ }
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);