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 +8 -0
- package/index.d.ts +3 -1
- package/package.json +1 -1
- package/src/PeekInside.js +23 -3
- package/src/alerts/rule-matcher.js +158 -0
- package/src/crypto.js +26 -2
- package/src/exceptions/exception-record.js +11 -1
- package/src/hub/ingest-payload.js +23 -2
- package/src/hub/server.js +3 -3
- package/src/instrumentation/jobs.js +21 -4
- package/src/runtime/request-counter.js +40 -0
- package/src/runtime/sampler.js +120 -6
- package/src/transport/cloud.js +79 -7
- package/src/user.js +18 -7
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
|
[](https://www.npmjs.com/package/peekinside)
|
|
6
6
|

|
|
7
7
|

|
|
8
|
+
[](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
|
+
[](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
|
+
"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 -
|
|
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] =
|
|
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 = {
|
|
328
|
-
|
|
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
|
-
* /
|
|
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
|
|
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), {
|
|
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'
|
|
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
|
+
}
|
package/src/runtime/sampler.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Samples the process now and then: memory (to spot leaks, as growth over hours
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
+
}
|
package/src/transport/cloud.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
26
|
-
* libraries have them), or just
|
|
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,
|
|
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);
|