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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -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
+ }