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
package/src/hub/store.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk storage for the local hub: one folder per project under <data dir>/projects, each held
|
|
3
|
+
* by a ProjectLog (see project-log.js for the file format).
|
|
4
|
+
*
|
|
5
|
+
* The store is the registry: it loads projects at startup, creates them when an app first
|
|
6
|
+
* checks in, and routes every operation to the right project.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { ProjectLog } from './project-log.js';
|
|
12
|
+
|
|
13
|
+
// Project ids become folder names, so they can never contain separators or dots.
|
|
14
|
+
export const PROJECT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
15
|
+
export const DEFAULT_MAX_REQUESTS = 1000;
|
|
16
|
+
export const DEFAULT_MAX_EXCEPTIONS = 500;
|
|
17
|
+
export const DEFAULT_MAX_JOBS = 2000;
|
|
18
|
+
export const DEFAULT_MAX_LOGS = 10000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* All projects on this machine.
|
|
22
|
+
*/
|
|
23
|
+
export class Store {
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} dataDir - PeekInside data folder
|
|
26
|
+
* @param {{ maxRequests?: number, maxExceptions?: number }} [options] - Requests and exceptions
|
|
27
|
+
* kept per project (at least 10 each)
|
|
28
|
+
*/
|
|
29
|
+
constructor(dataDir, { maxRequests = DEFAULT_MAX_REQUESTS, maxExceptions = DEFAULT_MAX_EXCEPTIONS } = {}) {
|
|
30
|
+
this.dir = path.join(dataDir, 'projects');
|
|
31
|
+
this.maxRequests = Math.max(10, Math.floor(maxRequests) || DEFAULT_MAX_REQUESTS);
|
|
32
|
+
this.maxExceptions = Math.max(10, Math.floor(maxExceptions) || DEFAULT_MAX_EXCEPTIONS);
|
|
33
|
+
// Four segments per log: retention deletes a quarter of the records at a time.
|
|
34
|
+
this.segmentSize = Math.max(10, Math.ceil(this.maxRequests / 4));
|
|
35
|
+
this.exceptionSegmentSize = Math.max(10, Math.ceil(this.maxExceptions / 4));
|
|
36
|
+
/** @type {Map<string, ProjectLog>} */
|
|
37
|
+
this.projects = new Map();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loads the projects on disk. A project that can't be read is skipped (and logged), so one
|
|
42
|
+
* damaged folder never stops the hub.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Store} This store
|
|
45
|
+
*/
|
|
46
|
+
load() {
|
|
47
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
for (const id of fs.readdirSync(this.dir)) {
|
|
49
|
+
if (!PROJECT_ID.test(id)) continue;
|
|
50
|
+
try {
|
|
51
|
+
const project = ProjectLog.load(path.join(this.dir, id), id, this._limits());
|
|
52
|
+
this.projects.set(id, project);
|
|
53
|
+
// After registering: a segment that can't be deleted never hides the project.
|
|
54
|
+
project.enforceRetention();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`Skipping project ${id}: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Number of projects.
|
|
64
|
+
*
|
|
65
|
+
* @type {number}
|
|
66
|
+
*/
|
|
67
|
+
get size() {
|
|
68
|
+
return this.projects.size;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} projectId
|
|
73
|
+
* @returns {boolean} Whether the project exists
|
|
74
|
+
*/
|
|
75
|
+
has(projectId) {
|
|
76
|
+
return this.projects.has(projectId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates or refreshes a project and records that one of its processes checked in.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ id: string, name: string, root: string }} info
|
|
83
|
+
* @param {Object} instance - Process details: pid, port, framework, node, sdk, environment, startedAt
|
|
84
|
+
* @param {number} [now=Date.now()]
|
|
85
|
+
* @returns {ProjectLog}
|
|
86
|
+
*/
|
|
87
|
+
touch(info, instance, now = Date.now()) {
|
|
88
|
+
const project = this.ensure(info, now);
|
|
89
|
+
project.checkIn(info, instance, now);
|
|
90
|
+
return project;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a project if it doesn't exist yet, without counting a check-in: for records spooled
|
|
95
|
+
* by a process that has exited since.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ id: string, name: string, root: string }} info
|
|
98
|
+
* @param {number} [now=Date.now()]
|
|
99
|
+
* @returns {ProjectLog}
|
|
100
|
+
*/
|
|
101
|
+
ensure(info, now = Date.now()) {
|
|
102
|
+
let project = this.projects.get(info.id);
|
|
103
|
+
if (!project) {
|
|
104
|
+
project = ProjectLog.create(path.join(this.dir, info.id), info, now, this._limits());
|
|
105
|
+
this.projects.set(info.id, project);
|
|
106
|
+
} else {
|
|
107
|
+
project.rename(info);
|
|
108
|
+
}
|
|
109
|
+
return project;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Adds counts of requests that were not recorded because they reached a server (local mode
|
|
114
|
+
* is for development; servers need PeekInside Cloud).
|
|
115
|
+
*
|
|
116
|
+
* @param {string} projectId
|
|
117
|
+
* @param {{ kind: 'host' | 'client', value: string, count: number }[]} entries
|
|
118
|
+
* @param {number} [now=Date.now()]
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
noteBlocked(projectId, entries, now = Date.now()) {
|
|
122
|
+
const project = this.projects.get(projectId);
|
|
123
|
+
if (!project || !entries.length) return;
|
|
124
|
+
project.noteBlocked(entries, now);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stores new job runs; ones already stored are skipped.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} projectId
|
|
131
|
+
* @param {Object[]} records
|
|
132
|
+
* @returns {Object[]}
|
|
133
|
+
*/
|
|
134
|
+
appendJobs(projectId, records) {
|
|
135
|
+
const project = this.projects.get(projectId);
|
|
136
|
+
return project && records.length ? project.appendJobs(records) : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} projectId
|
|
141
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
142
|
+
* @returns {{ jobs: Object[], hasMore: boolean }|null}
|
|
143
|
+
*/
|
|
144
|
+
listJobs(projectId, query) {
|
|
145
|
+
const project = this.projects.get(projectId);
|
|
146
|
+
return project ? project.listJobs(query) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} projectId
|
|
151
|
+
* @param {string} jobRunId
|
|
152
|
+
* @returns {Object|null}
|
|
153
|
+
*/
|
|
154
|
+
jobDetail(projectId, jobRunId) {
|
|
155
|
+
const project = this.projects.get(projectId);
|
|
156
|
+
return project ? project.jobDetail(jobRunId) : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} projectId
|
|
161
|
+
* @returns {void}
|
|
162
|
+
*/
|
|
163
|
+
clearJobs(projectId) {
|
|
164
|
+
const project = this.projects.get(projectId);
|
|
165
|
+
if (project) project.clearJobs();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Stores new log lines; ones already stored are skipped.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} projectId
|
|
172
|
+
* @param {Object[]} records
|
|
173
|
+
* @returns {Object[]}
|
|
174
|
+
*/
|
|
175
|
+
appendLogs(projectId, records) {
|
|
176
|
+
const project = this.projects.get(projectId);
|
|
177
|
+
return project && records.length ? project.appendLogs(records) : [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} projectId
|
|
182
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
183
|
+
* @returns {{ logs: Object[], hasMore: boolean }|null}
|
|
184
|
+
*/
|
|
185
|
+
listLogs(projectId, query) {
|
|
186
|
+
const project = this.projects.get(projectId);
|
|
187
|
+
return project ? project.listLogs(query) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} projectId
|
|
192
|
+
* @param {string} logId
|
|
193
|
+
* @returns {Object|null}
|
|
194
|
+
*/
|
|
195
|
+
logDetail(projectId, logId) {
|
|
196
|
+
const project = this.projects.get(projectId);
|
|
197
|
+
return project ? project.logDetail(logId) : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} projectId
|
|
202
|
+
* @returns {void}
|
|
203
|
+
*/
|
|
204
|
+
clearLogs(projectId) {
|
|
205
|
+
const project = this.projects.get(projectId);
|
|
206
|
+
if (project) project.clearLogs();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Adds process samples of a project's process (kept in memory, for the Runtime page).
|
|
211
|
+
*
|
|
212
|
+
* @param {string} projectId
|
|
213
|
+
* @param {Object} instance - Validated process details
|
|
214
|
+
* @param {Object[]} samples - Validated samples
|
|
215
|
+
* @param {number} [now=Date.now()]
|
|
216
|
+
* @returns {void}
|
|
217
|
+
*/
|
|
218
|
+
addSamples(projectId, instance, samples, now = Date.now()) {
|
|
219
|
+
const project = this.projects.get(projectId);
|
|
220
|
+
if (project) project.addSamples(instance, samples, now);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A project's recent processes with their samples.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} projectId
|
|
227
|
+
* @param {number} [now=Date.now()]
|
|
228
|
+
* @returns {{ processes: Object[] }|null} null for an unknown project
|
|
229
|
+
*/
|
|
230
|
+
runtime(projectId, now = Date.now()) {
|
|
231
|
+
const project = this.projects.get(projectId);
|
|
232
|
+
return project ? project.runtimeHistory(now) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stores new requests. Requests already stored (same id) are skipped, so retried batches are harmless.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} projectId
|
|
239
|
+
* @param {Object[]} records - Validated records
|
|
240
|
+
* @returns {Object[]} Summaries of the stored requests
|
|
241
|
+
*/
|
|
242
|
+
append(projectId, records) {
|
|
243
|
+
const project = this.projects.get(projectId);
|
|
244
|
+
return project ? project.append(records) : [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Summaries, newest first; oldest first when `after` is given (for polling new requests).
|
|
249
|
+
*
|
|
250
|
+
* @param {string} projectId
|
|
251
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
252
|
+
* @returns {{ requests: Object[], hasMore: boolean } | null} null for an unknown project
|
|
253
|
+
*/
|
|
254
|
+
list(projectId, query) {
|
|
255
|
+
const project = this.projects.get(projectId);
|
|
256
|
+
return project ? project.list(query) : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The full detail of one request.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} projectId
|
|
263
|
+
* @param {string} requestId
|
|
264
|
+
* @returns {Object|null} null when it is no longer stored
|
|
265
|
+
*/
|
|
266
|
+
detail(projectId, requestId) {
|
|
267
|
+
const project = this.projects.get(projectId);
|
|
268
|
+
return project ? project.detail(requestId) : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Deletes a project's requests.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} projectId
|
|
275
|
+
* @returns {boolean} False for an unknown project
|
|
276
|
+
*/
|
|
277
|
+
clear(projectId) {
|
|
278
|
+
const project = this.projects.get(projectId);
|
|
279
|
+
if (!project) return false;
|
|
280
|
+
project.clear();
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Stores new exceptions; ones already stored (same id) are skipped.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} projectId
|
|
288
|
+
* @param {Object[]} records - Validated exceptions
|
|
289
|
+
* @returns {Object[]} Summaries of the stored exceptions
|
|
290
|
+
*/
|
|
291
|
+
appendExceptions(projectId, records) {
|
|
292
|
+
const project = this.projects.get(projectId);
|
|
293
|
+
return project && records.length ? project.appendExceptions(records) : [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Exception summaries, newest first; oldest first when `after` is given.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} projectId
|
|
300
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
301
|
+
* @returns {{ exceptions: Object[], hasMore: boolean } | null} null for an unknown project
|
|
302
|
+
*/
|
|
303
|
+
listExceptions(projectId, query) {
|
|
304
|
+
const project = this.projects.get(projectId);
|
|
305
|
+
return project ? project.listExceptions(query) : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The full detail of one exception.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} projectId
|
|
312
|
+
* @param {string} exceptionId
|
|
313
|
+
* @returns {Object|null} null when it is no longer stored
|
|
314
|
+
*/
|
|
315
|
+
exceptionDetail(projectId, exceptionId) {
|
|
316
|
+
const project = this.projects.get(projectId);
|
|
317
|
+
return project ? project.exceptionDetail(exceptionId) : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Deletes a project's exceptions.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} projectId
|
|
324
|
+
* @returns {boolean} False for an unknown project
|
|
325
|
+
*/
|
|
326
|
+
clearExceptions(projectId) {
|
|
327
|
+
const project = this.projects.get(projectId);
|
|
328
|
+
if (!project) return false;
|
|
329
|
+
project.clearExceptions();
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The project's exception state (resolved fingerprints) and its version.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} projectId
|
|
337
|
+
* @returns {{ state: Object, version: number } | null} null for an unknown project
|
|
338
|
+
*/
|
|
339
|
+
exceptionState(projectId) {
|
|
340
|
+
const project = this.projects.get(projectId);
|
|
341
|
+
return project ? project.exceptionState.read() : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Saves the project's exception state if `version` is the current one.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} projectId
|
|
348
|
+
* @param {Object} state - Validated state (see ExceptionState.validate())
|
|
349
|
+
* @param {number} version - The version the dashboard read
|
|
350
|
+
* @returns {{ saved: boolean, state: Object, version: number } | null} null for an unknown project
|
|
351
|
+
*/
|
|
352
|
+
saveExceptionState(projectId, state, version) {
|
|
353
|
+
const project = this.projects.get(projectId);
|
|
354
|
+
return project ? project.exceptionState.write(state, version) : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Forgets a project and deletes its folder.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} projectId
|
|
361
|
+
* @returns {boolean} False for an unknown project
|
|
362
|
+
*/
|
|
363
|
+
remove(projectId) {
|
|
364
|
+
const project = this.projects.get(projectId);
|
|
365
|
+
if (!project) return false;
|
|
366
|
+
project.remove();
|
|
367
|
+
this.projects.delete(projectId);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Projects with their request counts and running processes, most recently active first.
|
|
373
|
+
*
|
|
374
|
+
* @param {number} [now=Date.now()]
|
|
375
|
+
* @returns {Object[]}
|
|
376
|
+
*/
|
|
377
|
+
listProjects(now = Date.now()) {
|
|
378
|
+
const result = [];
|
|
379
|
+
for (const project of this.projects.values()) {
|
|
380
|
+
result.push(project.describe(now));
|
|
381
|
+
}
|
|
382
|
+
return result.sort((a, b) => Number(b.online) - Number(a.online) || b.lastSeenAt - a.lastSeenAt);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Writes changed project metadata (last-seen times) to disk.
|
|
387
|
+
*
|
|
388
|
+
* @returns {void}
|
|
389
|
+
*/
|
|
390
|
+
persist() {
|
|
391
|
+
for (const project of this.projects.values()) project.persist();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_limits() {
|
|
395
|
+
return {
|
|
396
|
+
maxRequests: this.maxRequests,
|
|
397
|
+
segmentSize: this.segmentSize,
|
|
398
|
+
maxExceptions: this.maxExceptions,
|
|
399
|
+
exceptionSegmentSize: this.exceptionSegmentSize,
|
|
400
|
+
maxJobs: DEFAULT_MAX_JOBS,
|
|
401
|
+
maxLogs: DEFAULT_MAX_LOGS
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console lines: console.log/info/warn/error/debug, and console.trace. A line written while a
|
|
3
|
+
* request (or a job run) is handled goes on its timeline; every line goes to the Logs page (see
|
|
4
|
+
* LogCapture). What the console prints is untouched: the original method runs with the same
|
|
5
|
+
* arguments.
|
|
6
|
+
*
|
|
7
|
+
* console.trace is not wrapped: the stack it prints would include the wrapper. It writes through
|
|
8
|
+
* console.error, so it is recognized there instead (a single "Trace: ..." string, written by
|
|
9
|
+
* console.trace itself) and recorded at debug level, as the contract maps it.
|
|
10
|
+
*
|
|
11
|
+
* PeekInside's own lines ("[peekinside] ...") are never recorded.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { format } from 'util';
|
|
15
|
+
import { LOG_PREFIX } from '../config.js';
|
|
16
|
+
import { LogCapture } from '../logs/log-capture.js';
|
|
17
|
+
import { Timeline } from '../timeline/timeline.js';
|
|
18
|
+
|
|
19
|
+
const LEVELS = { log: 'info', info: 'info', warn: 'warn', error: 'error', debug: 'debug' };
|
|
20
|
+
const TRACE = /^Trace(?:: |\n|$)/;
|
|
21
|
+
let installed = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps the global console's methods, once per process.
|
|
25
|
+
*/
|
|
26
|
+
export class ConsoleInstrumentation {
|
|
27
|
+
/**
|
|
28
|
+
* @returns {void}
|
|
29
|
+
*/
|
|
30
|
+
static install() {
|
|
31
|
+
if (installed) return;
|
|
32
|
+
installed = true;
|
|
33
|
+
for (const [method, level] of Object.entries(LEVELS)) {
|
|
34
|
+
const original = console[method];
|
|
35
|
+
if (typeof original !== 'function') continue;
|
|
36
|
+
const wrapper = {
|
|
37
|
+
[method](...args) {
|
|
38
|
+
record(level, args, wrapper);
|
|
39
|
+
return original.apply(this, arguments);
|
|
40
|
+
}
|
|
41
|
+
}[method];
|
|
42
|
+
console[method] = wrapper;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function record(level, args, wrapper) {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof args[0] === 'string' && args[0].startsWith(LOG_PREFIX)) return;
|
|
50
|
+
const timeline = Timeline.current();
|
|
51
|
+
const onTimeline = Boolean(timeline && timeline.settings.captureLogs);
|
|
52
|
+
let line = null;
|
|
53
|
+
if (level === 'error' && args.length === 1 && typeof args[0] === 'string' && TRACE.test(args[0]) && calledByTrace(wrapper)) {
|
|
54
|
+
const text = args[0];
|
|
55
|
+
const frames = text.indexOf('\n at ');
|
|
56
|
+
const head = frames === -1 ? text : text.slice(0, frames);
|
|
57
|
+
line = { level: 'debug', message: head.startsWith('Trace: ') ? head.slice(7) : '' };
|
|
58
|
+
}
|
|
59
|
+
const lineLevel = line ? line.level : level;
|
|
60
|
+
const toLogs = LogCapture.wants(lineLevel);
|
|
61
|
+
if (!onTimeline && !toLogs) return;
|
|
62
|
+
if (!line) line = { level, message: format(...args) };
|
|
63
|
+
if (onTimeline) timeline.log(line.level, line.message);
|
|
64
|
+
if (toLogs) LogCapture.record(line.level, line.message, { source: 'console' });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// A value whose formatting throws is printed (or not) by the console itself.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether console.error was called by console.trace: its frame is right below the wrapper's.
|
|
71
|
+
function calledByTrace(wrapper) {
|
|
72
|
+
const saved = Error.stackTraceLimit;
|
|
73
|
+
const holder = {};
|
|
74
|
+
try {
|
|
75
|
+
Error.stackTraceLimit = 2;
|
|
76
|
+
Error.captureStackTrace(holder, wrapper);
|
|
77
|
+
return /\bat (?:console\.|Console\.)?trace\b/.test(String(holder.stack));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return false;
|
|
80
|
+
} finally {
|
|
81
|
+
Error.stackTraceLimit = saved;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outgoing HTTP calls on the request's timeline: node:http/https requests (and every client built
|
|
3
|
+
* on them: axios, got, node-fetch...) and fetch (undici, built into Node.js).
|
|
4
|
+
*
|
|
5
|
+
* Both are observed through diagnostics_channel, which Node.js publishes to without any patching:
|
|
6
|
+
*
|
|
7
|
+
* - node:http: 'http.client.request.start' (in the app's context, when the request is created)
|
|
8
|
+
* starts the span; 'http.client.response.finish' (when the response's headers arrive) gives the
|
|
9
|
+
* status; the span ends when the response's body has been read. Errors are seen by wrapping the
|
|
10
|
+
* request's and the response's emit(), never by adding an 'error' listener, which would change
|
|
11
|
+
* what an unhandled error does.
|
|
12
|
+
* - fetch: 'undici:request:create' starts the span, 'undici:request:headers' gives the status,
|
|
13
|
+
* 'undici:request:trailers' (the body is complete) or 'undici:request:error' ends it.
|
|
14
|
+
*
|
|
15
|
+
* Channels that don't exist in the running Node.js version are simply never published to.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import dc from 'diagnostics_channel';
|
|
19
|
+
import { Timeline } from '../timeline/timeline.js';
|
|
20
|
+
|
|
21
|
+
let installed = false;
|
|
22
|
+
// node:http requests and undici requests -> their span and status.
|
|
23
|
+
const calls = new WeakMap();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subscribes to the channels, once per process.
|
|
27
|
+
*/
|
|
28
|
+
export class HttpInstrumentation {
|
|
29
|
+
/**
|
|
30
|
+
* @returns {void}
|
|
31
|
+
*/
|
|
32
|
+
static install() {
|
|
33
|
+
if (installed || typeof dc.subscribe !== 'function') return;
|
|
34
|
+
installed = true;
|
|
35
|
+
subscribe('http.client.request.start', onRequestStart);
|
|
36
|
+
subscribe('http.client.response.finish', onResponse);
|
|
37
|
+
subscribe('undici:request:create', onFetchCreate);
|
|
38
|
+
subscribe('undici:request:headers', onFetchHeaders);
|
|
39
|
+
subscribe('undici:request:trailers', onFetchTrailers);
|
|
40
|
+
subscribe('undici:request:error', onFetchError);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribe(name, handler) {
|
|
45
|
+
try {
|
|
46
|
+
dc.subscribe(name, (message) => {
|
|
47
|
+
try {
|
|
48
|
+
handler(message);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// An observer must never break the app's call.
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Channel unavailable in this Node.js version.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- node:http ---------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function onRequestStart({ request }) {
|
|
61
|
+
const timeline = Timeline.current();
|
|
62
|
+
if (!timeline || !request || calls.has(request)) return;
|
|
63
|
+
// `host` is the hostname option; strip a port only from "name:port", never from an IPv6 address.
|
|
64
|
+
const raw = String(request.host || '');
|
|
65
|
+
const host = raw.split(':').length === 2 ? raw.slice(0, raw.indexOf(':')) : raw.replace(/^\[|\]$/g, '');
|
|
66
|
+
const span = timeline.http({ client: 'http', method: request.method, url: urlOf(request), host });
|
|
67
|
+
if (!span) return;
|
|
68
|
+
const call = { span, status: null };
|
|
69
|
+
calls.set(request, call);
|
|
70
|
+
watch(request, (event, value) => {
|
|
71
|
+
if (event === 'error') span.end({ status: call.status, error: value });
|
|
72
|
+
else if (event === 'close' && call.status === null) span.end({ error: 'socket closed before a response' });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onResponse({ request, response }) {
|
|
77
|
+
const call = request && calls.get(request);
|
|
78
|
+
if (!call) return;
|
|
79
|
+
call.status = response && Number.isInteger(response.statusCode) ? response.statusCode : null;
|
|
80
|
+
if (!response || typeof response.emit !== 'function') {
|
|
81
|
+
call.span.end({ status: call.status });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
watch(response, (event, value) => {
|
|
85
|
+
if (event === 'end' || event === 'close') call.span.end({ status: call.status });
|
|
86
|
+
else if (event === 'error') call.span.end({ status: call.status, error: value });
|
|
87
|
+
else if (event === 'aborted') call.span.end({ status: call.status, error: 'response aborted' });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Sees an emitter's events without becoming a listener.
|
|
92
|
+
function watch(emitter, observe) {
|
|
93
|
+
if (!emitter || typeof emitter.emit !== 'function') return;
|
|
94
|
+
const emit = emitter.emit;
|
|
95
|
+
emitter.emit = function (event, value) {
|
|
96
|
+
try {
|
|
97
|
+
observe(event, value);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Ignore.
|
|
100
|
+
}
|
|
101
|
+
return emit.apply(this, arguments);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function urlOf(request) {
|
|
106
|
+
const path = String(request.path || '/');
|
|
107
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path; // absolute-form, through a proxy
|
|
108
|
+
let host;
|
|
109
|
+
try {
|
|
110
|
+
host = request.getHeader('host');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
host = undefined;
|
|
113
|
+
}
|
|
114
|
+
return `${request.protocol || 'http:'}//${host || request.host || 'localhost'}${path}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- fetch (undici) ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function onFetchCreate({ request }) {
|
|
120
|
+
const timeline = Timeline.current();
|
|
121
|
+
if (!timeline || !request) return;
|
|
122
|
+
const origin = String(request.origin || '');
|
|
123
|
+
let host = '';
|
|
124
|
+
try {
|
|
125
|
+
host = new URL(origin).hostname;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
host = '';
|
|
128
|
+
}
|
|
129
|
+
const span = timeline.http({ client: 'fetch', method: request.method, url: `${origin}${request.path || ''}`, host });
|
|
130
|
+
if (span) calls.set(request, { span, status: null });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onFetchHeaders({ request, response }) {
|
|
134
|
+
const call = request && calls.get(request);
|
|
135
|
+
if (call && response) call.status = Number.isInteger(response.statusCode) ? response.statusCode : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function onFetchTrailers({ request }) {
|
|
139
|
+
const call = request && calls.get(request);
|
|
140
|
+
if (call) call.span.end({ status: call.status });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onFetchError({ request, error }) {
|
|
144
|
+
const call = request && calls.get(request);
|
|
145
|
+
if (call) call.span.end({ status: call.status, error });
|
|
146
|
+
}
|