peekinside 0.0.0-stage → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +538 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- package/src/user.js +83 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends captured exchanges and exceptions to the local hub, the free dashboard shared by every
|
|
3
|
+
* app on this machine, and starts the hub when it isn't running.
|
|
4
|
+
*
|
|
5
|
+
* Records are batched and sent from timers, never on the request path. While the hub starts
|
|
6
|
+
* (or restarts) they wait in a bounded queue. A heartbeat keeps the app listed as running.
|
|
7
|
+
* A crashing process writes what it has to a spool file instead, which the hub picks up.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { LOG_PREFIX, VERSION, settings } from '../config.js';
|
|
12
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
13
|
+
import { HubClient } from '../hub/client.js';
|
|
14
|
+
import { RequestRecord } from '../record.js';
|
|
15
|
+
import { Spool } from '../spool.js';
|
|
16
|
+
import { BatchTransport } from './batch.js';
|
|
17
|
+
|
|
18
|
+
const FLUSH_DELAY_MS = 50;
|
|
19
|
+
const BATCH_SIZE = 200;
|
|
20
|
+
const MAX_QUEUE = 2000;
|
|
21
|
+
const HEARTBEAT_MS = 10_000;
|
|
22
|
+
const SPAWN_INTERVAL_MS = 10_000;
|
|
23
|
+
const MAX_SPAWNS = 5;
|
|
24
|
+
const FIRST_BACKOFF_MS = 250;
|
|
25
|
+
const MAX_BACKOFF_MS = 5000;
|
|
26
|
+
const MAX_SERVER_WARNINGS = 3;
|
|
27
|
+
const MAX_BLOCKED_HOSTS = 10;
|
|
28
|
+
// What one spool file holds at most: the hub's limits for one batch.
|
|
29
|
+
const MAX_SPOOLED_REQUESTS = 1000;
|
|
30
|
+
const MAX_SPOOLED_EXCEPTIONS = 500;
|
|
31
|
+
// Framework reported until an integration names one.
|
|
32
|
+
const DEFAULT_FRAMEWORK = 'node';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Posts batches to the hub's /api/ingest, with this process's details: which project it runs,
|
|
36
|
+
* its port and framework, and counts of server requests it didn't record.
|
|
37
|
+
*/
|
|
38
|
+
export class LocalTransport extends BatchTransport {
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} options
|
|
41
|
+
* @param {import('../project.js').ProjectIdentity} options.project - The app's project
|
|
42
|
+
* @param {string} [options.framework="node"] - Shown next to the app in the dashboard
|
|
43
|
+
* @param {string} [options.environment] - Environment label (default "development")
|
|
44
|
+
* @param {string|null} [options.release] - The running release (see release.js)
|
|
45
|
+
*/
|
|
46
|
+
constructor({ project, framework = DEFAULT_FRAMEWORK, environment, release = null }) {
|
|
47
|
+
super({
|
|
48
|
+
environment: RequestRecord.normalizeEnvironment(environment, 'development'),
|
|
49
|
+
release,
|
|
50
|
+
batchSize: BATCH_SIZE,
|
|
51
|
+
maxQueue: MAX_QUEUE,
|
|
52
|
+
firstBackoffMs: FIRST_BACKOFF_MS,
|
|
53
|
+
maxBackoffMs: MAX_BACKOFF_MS
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.hub = new HubClient(settings.hubPort(), { keepAlive: true });
|
|
57
|
+
this.project = project;
|
|
58
|
+
/**
|
|
59
|
+
* This process, as the dashboard lists it
|
|
60
|
+
* @type {{ pid: number, port: number|null, framework: string, node: string, sdk: string, environment: string, release: string|null, startedAt: number }}
|
|
61
|
+
*/
|
|
62
|
+
this.instance = {
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
port: null,
|
|
65
|
+
framework,
|
|
66
|
+
node: process.version,
|
|
67
|
+
sdk: VERSION,
|
|
68
|
+
environment: this.environment,
|
|
69
|
+
release: this.release,
|
|
70
|
+
startedAt: Date.now()
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
this.timer = null;
|
|
74
|
+
this.heartbeat = null;
|
|
75
|
+
this.started = false;
|
|
76
|
+
this.announced = false;
|
|
77
|
+
this.lastSpawn = 0;
|
|
78
|
+
this.spawns = 0;
|
|
79
|
+
this.blocked = new BlockedRequests();
|
|
80
|
+
this.blockedWarnings = new Set();
|
|
81
|
+
this.outdatedHubNoted = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Registers this process with the hub (starting the hub if needed) and keeps it registered.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} [appPort] - Port the app listens on, shown in the dashboard
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
start(appPort) {
|
|
91
|
+
if (appPort) this.instance.port = appPort;
|
|
92
|
+
if (this.disabled) return;
|
|
93
|
+
// The heartbeat timer must not keep (and report into) the context of whatever started it.
|
|
94
|
+
RequestContext.outside(() => this._start());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_start() {
|
|
98
|
+
if (!this.started) {
|
|
99
|
+
this.started = true;
|
|
100
|
+
this.heartbeat = setInterval(() => this.flush(true), HEARTBEAT_MS);
|
|
101
|
+
if (typeof this.heartbeat.unref === 'function') this.heartbeat.unref();
|
|
102
|
+
// Each time the event loop empties, what is queued is sent, so a script's last requests
|
|
103
|
+
// reach the hub.
|
|
104
|
+
process.on('beforeExit', this._onBeforeExit);
|
|
105
|
+
}
|
|
106
|
+
this.flush(true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Names the app's framework in the dashboard. The first integration used wins.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} name - Such as "express"
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
setFramework(name) {
|
|
116
|
+
if (this.instance.framework === DEFAULT_FRAMEWORK) {
|
|
117
|
+
this.instance.framework = name;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Counts a request that wasn't recorded because it reached a server; the dashboard shows that
|
|
123
|
+
* servers need PeekInside Cloud. The console says so too, for the first few hosts.
|
|
124
|
+
*
|
|
125
|
+
* @param {import('../deployment.js').ServerSignal} signal - From TrafficClassifier#serverSignal()
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
128
|
+
noteServerRequest(signal) {
|
|
129
|
+
if (this.disabled) return;
|
|
130
|
+
const key = this.blocked.count(signal);
|
|
131
|
+
if (!this.blockedWarnings.has(key) && this.blockedWarnings.size < MAX_SERVER_WARNINGS) {
|
|
132
|
+
this.blockedWarnings.add(key);
|
|
133
|
+
const what = signal.kind === 'host' ? `requests to ${signal.value}` : `requests from the public address ${signal.value}`;
|
|
134
|
+
console.warn(`${LOG_PREFIX} Not recording ${what}: the free local dashboard is for development on your machine. Servers need PeekInside Cloud: ${settings.siteUrl()}/#pricing`);
|
|
135
|
+
}
|
|
136
|
+
RequestContext.outside(() => this._wake());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stops sending and the heartbeat, and closes the connections to the hub.
|
|
141
|
+
*
|
|
142
|
+
* @returns {void}
|
|
143
|
+
*/
|
|
144
|
+
stop() {
|
|
145
|
+
this.disabled = true;
|
|
146
|
+
clearInterval(this.heartbeat);
|
|
147
|
+
clearTimeout(this.timer);
|
|
148
|
+
this.timer = null;
|
|
149
|
+
super.stop();
|
|
150
|
+
this.hub.close();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_hasPending() {
|
|
154
|
+
return this.queue.length > 0 || this.blocked.size > 0 || this.samples.length > 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_onQueued() {
|
|
158
|
+
this._wake();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_onBackingOff() {
|
|
162
|
+
this._schedule();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_afterAttempt() {
|
|
166
|
+
if (this._hasPending()) this._schedule();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Writes the records to a spool file for the hub, synchronously: the process may exit right
|
|
171
|
+
* after. The hub ingests spool files at startup and every second. When the process is exiting,
|
|
172
|
+
* whatever is still queued (or was being sent) goes into the file too.
|
|
173
|
+
*
|
|
174
|
+
* @param {Object} batch - See BatchTransport#deliverNow()
|
|
175
|
+
* @returns {void}
|
|
176
|
+
*/
|
|
177
|
+
deliverNow({ exceptions, exchanges, fatal }) {
|
|
178
|
+
if (this.disabled) return;
|
|
179
|
+
try {
|
|
180
|
+
// The hub skips records it already has, so a batch that was in flight may safely go again.
|
|
181
|
+
const records = fatal ? [...this.sending, ...this.queue.splice(0)] : [];
|
|
182
|
+
for (const exchange of exchanges) records.push(RequestRecord.fromExchange(exchange, this.environment, this.release));
|
|
183
|
+
records.push(...exceptions);
|
|
184
|
+
const { requests, exceptions: errors, jobs, logs } = BatchTransport.split(records);
|
|
185
|
+
Spool.local(settings.dataDir(), this.project.id).write({
|
|
186
|
+
project: this.project,
|
|
187
|
+
instance: this.instance,
|
|
188
|
+
requests: requests.slice(-MAX_SPOOLED_REQUESTS),
|
|
189
|
+
exceptions: errors.slice(-MAX_SPOOLED_EXCEPTIONS),
|
|
190
|
+
jobs: jobs.slice(-MAX_SPOOLED_EXCEPTIONS),
|
|
191
|
+
logs: logs.slice(-MAX_SPOOLED_REQUESTS)
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// A data folder that can't be written loses the records, never the app.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Sends the batch with the blocked-request counts gathered since the last batch. Records are
|
|
199
|
+
// dropped only when the hub refuses them; when it isn't reachable, both are kept for later.
|
|
200
|
+
async _deliver(batch) {
|
|
201
|
+
const blocked = this.blocked.take();
|
|
202
|
+
const samples = this.takeSamples();
|
|
203
|
+
const { requests, exceptions, jobs, logs } = BatchTransport.split(batch);
|
|
204
|
+
try {
|
|
205
|
+
const answer = await this.hub.request('POST', '/api/ingest', {
|
|
206
|
+
body: { project: this.project, instance: this.instance, requests, exceptions, jobs, logs, samples, blocked: blocked.map(([, entry]) => entry) }
|
|
207
|
+
});
|
|
208
|
+
if (exceptions.length && answer.acceptedExceptions === undefined) this._noteOutdatedHub();
|
|
209
|
+
else if ((jobs.length || logs.length) && answer.acceptedJobs === undefined) this._noteOutdatedHub();
|
|
210
|
+
return true;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code === 'NOT_HUB') {
|
|
213
|
+
this._disable(`Port ${this.hub.port} is used by another program, so the local dashboard can't start. Set PEEKINSIDE_HUB_PORT to a free port.`);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (error.status === 400 || error.status === 413) {
|
|
217
|
+
// Sending the same data again would fail again.
|
|
218
|
+
this._warn(`The local dashboard rejected ${batch.length} records: ${error.message}`);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (error.status >= 401 && error.status < 500) {
|
|
222
|
+
this._disable(`The local dashboard refused requests (${error.message}). Restart it with: npx peekinside stop`);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Not running, starting up, or restarting: keep everything and try again shortly.
|
|
227
|
+
this.blocked.restore(blocked);
|
|
228
|
+
this.restoreSamples(samples);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A hub started before this version keeps running until it is restarted, and ignores what it
|
|
234
|
+
// doesn't know (exceptions, job runs, log lines).
|
|
235
|
+
_noteOutdatedHub() {
|
|
236
|
+
if (this.outdatedHubNoted) return;
|
|
237
|
+
this.outdatedHubNoted = true;
|
|
238
|
+
console.warn(`${LOG_PREFIX} The local dashboard on port ${this.hub.port} is an older version that doesn't store everything this app sends. Restart it to see it all: npx peekinside stop`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_onDelivered() {
|
|
242
|
+
this.spawns = 0;
|
|
243
|
+
if (!this.announced) {
|
|
244
|
+
this.announced = true;
|
|
245
|
+
console.log(`${LOG_PREFIX} Requests from ${this.project.name}: ${this.hub.url}/p/${this.project.id}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_onRetry(error) {
|
|
250
|
+
if (error.code === 'ECONNREFUSED') this._startHub();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Sends soon if the transport is running, or starts it (the first request may come before the
|
|
254
|
+
// app's port is known).
|
|
255
|
+
_wake() {
|
|
256
|
+
if (this.started) this._schedule();
|
|
257
|
+
else this.start();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Sends after a short delay, so requests arriving together go in one batch; after a failure,
|
|
261
|
+
// waits for the backoff to end.
|
|
262
|
+
_schedule() {
|
|
263
|
+
if (this.timer || this.inflight || this.disabled) return;
|
|
264
|
+
const wait = Math.max(FLUSH_DELAY_MS, this.nextAttempt - Date.now());
|
|
265
|
+
this.timer = setTimeout(() => {
|
|
266
|
+
this.timer = null;
|
|
267
|
+
this.flush();
|
|
268
|
+
}, wait);
|
|
269
|
+
if (typeof this.timer.unref === 'function') this.timer.unref();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Nothing listens on the hub's port: start the hub, at most every SPAWN_INTERVAL_MS and
|
|
273
|
+
// MAX_SPAWNS times in a row, so a hub that crashes on start isn't respawned forever.
|
|
274
|
+
_startHub() {
|
|
275
|
+
if (Date.now() - this.lastSpawn < SPAWN_INTERVAL_MS) return;
|
|
276
|
+
if (this.spawns >= MAX_SPAWNS) {
|
|
277
|
+
this._warn(`The local dashboard isn't starting; see ${path.join(settings.dataDir(), 'hub.log')}. Requests are kept until it runs (start it with: npx peekinside).`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.lastSpawn = Date.now();
|
|
281
|
+
this.spawns++;
|
|
282
|
+
try {
|
|
283
|
+
this.hub.spawn();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this._warn(`Could not start the local dashboard: ${error.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Requests that were not recorded because they reached a server, counted until the hub has
|
|
292
|
+
* them: one entry per public host name, and one for public clients behind a proxy.
|
|
293
|
+
*
|
|
294
|
+
* Entries are bounded: past MAX_BLOCKED_HOSTS entries, further host names fold into "*", so an
|
|
295
|
+
* app reachable under many names can't grow the batch without limit.
|
|
296
|
+
*/
|
|
297
|
+
class BlockedRequests {
|
|
298
|
+
/**
|
|
299
|
+
* Starts with no counts.
|
|
300
|
+
*/
|
|
301
|
+
constructor() {
|
|
302
|
+
// Key ("host <name>", "host *" or "client") -> { kind, value, count }, in first-seen order.
|
|
303
|
+
this._entries = new Map();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Number of entries.
|
|
308
|
+
*
|
|
309
|
+
* @type {number}
|
|
310
|
+
*/
|
|
311
|
+
get size() {
|
|
312
|
+
return this._entries.size;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Counts one request.
|
|
317
|
+
*
|
|
318
|
+
* @param {import('../deployment.js').ServerSignal} signal
|
|
319
|
+
* @returns {string} The entry it was counted in
|
|
320
|
+
*/
|
|
321
|
+
count(signal) {
|
|
322
|
+
let key = signal.kind === 'host' ? `host ${signal.value}` : 'client';
|
|
323
|
+
if (signal.kind === 'host' && !this._entries.has(key) && this._entries.size >= MAX_BLOCKED_HOSTS) key = 'host *';
|
|
324
|
+
this._add(key, { kind: signal.kind, value: key === 'host *' ? '*' : signal.value, count: 1 });
|
|
325
|
+
return key;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Removes all entries, to send them.
|
|
330
|
+
*
|
|
331
|
+
* @returns {Array<[string, { kind: string, value: string, count: number }]>}
|
|
332
|
+
*/
|
|
333
|
+
take() {
|
|
334
|
+
const taken = [...this._entries];
|
|
335
|
+
this._entries.clear();
|
|
336
|
+
return taken;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Puts back entries that could not be sent, adding them to anything counted since.
|
|
341
|
+
*
|
|
342
|
+
* @param {Array<[string, { kind: string, value: string, count: number }]>} taken - From take()
|
|
343
|
+
* @returns {void}
|
|
344
|
+
*/
|
|
345
|
+
restore(taken) {
|
|
346
|
+
for (const [key, entry] of taken) this._add(key, entry);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_add(key, entry) {
|
|
350
|
+
const current = this._entries.get(key);
|
|
351
|
+
// For proxied clients, the latest address is kept as an example.
|
|
352
|
+
this._entries.set(key, current ? { ...current, value: entry.value, count: current.count + entry.count } : { ...entry });
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/user.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a request or an exception was for, as the app tells PeekInside (PeekInside.setUser()), so the
|
|
3
|
+
* dashboard can count the users an issue affects and list everything one customer ran into.
|
|
4
|
+
*
|
|
5
|
+
* A user is personal data: PeekInside Cloud only ever receives it sealed, inside the summary of
|
|
6
|
+
* the request or exception, so only the project's browsers can read it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MAX_ID = 128;
|
|
10
|
+
const MAX_EMAIL = 254;
|
|
11
|
+
const MAX_NAME = 128;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} User
|
|
15
|
+
* @property {string|null} id
|
|
16
|
+
* @property {string|null} email
|
|
17
|
+
* @property {string|null} name
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes what apps pass as a user.
|
|
22
|
+
*/
|
|
23
|
+
export class UserInfo {
|
|
24
|
+
/**
|
|
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
|
+
*
|
|
28
|
+
* @param {*} user
|
|
29
|
+
* @returns {User|null} null when it names nobody
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* UserInfo.normalize({ id: 42, email: 'ada@example.com' }) // { id: '42', email: 'ada@example.com', name: null }
|
|
33
|
+
*/
|
|
34
|
+
static normalize(user) {
|
|
35
|
+
if (user === null || user === undefined) return null;
|
|
36
|
+
if (typeof user !== 'object') return UserInfo.normalize({ id: user });
|
|
37
|
+
let id;
|
|
38
|
+
let email;
|
|
39
|
+
let name;
|
|
40
|
+
try {
|
|
41
|
+
id = firstDefined(user.id, user._id, user.userId);
|
|
42
|
+
email = user.email;
|
|
43
|
+
name = firstDefined(user.name, user.username);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
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;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One string that tells users apart: the id, else the email, else the name.
|
|
53
|
+
*
|
|
54
|
+
* @param {User|null} user
|
|
55
|
+
* @returns {string|null}
|
|
56
|
+
*/
|
|
57
|
+
static key(user) {
|
|
58
|
+
return user ? user.id || user.email || user.name || null : null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function firstDefined(...values) {
|
|
63
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Strings and numbers as they are; objects (a MongoDB ObjectId) by their own toString().
|
|
67
|
+
function identifier(value) {
|
|
68
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') return label(String(value), MAX_ID);
|
|
69
|
+
if (value && typeof value === 'object') {
|
|
70
|
+
try {
|
|
71
|
+
if (typeof value.toString === 'function' && value.toString !== Object.prototype.toString) return label(String(value), MAX_ID);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function label(value, max) {
|
|
80
|
+
if (typeof value !== 'string') return null;
|
|
81
|
+
const clean = value.trim().slice(0, max);
|
|
82
|
+
return clean || null;
|
|
83
|
+
}
|