@forge-ops/tracker 0.10.0 → 0.11.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/README.md CHANGED
@@ -113,6 +113,28 @@ themselves, long before it would ever reach here.
113
113
  Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
114
114
  dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
115
115
 
116
+ ## Where an error happened
117
+
118
+ An error reported during a request carries three extra fields:
119
+
120
+ - `transaction_name`: the method and the route pattern, `"GET /orders/:id"`, the same name
121
+ performance monitoring and traces use.
122
+ - `endpoint`: the method and the route as declared, `"GET /orders/:id"`. Never the literal path, so
123
+ an id or a token in the URL never ends up here. On Express, it's the route as declared on the
124
+ router that matched it: for a router mounted with `app.use("/api", router)`, that's the part
125
+ declared on the router, without `/api`, since the mount path Express exposes is the literal
126
+ matched one.
127
+ - `trace_id`: the request's W3C trace id, which ForgeOps uses to link this error to errors that
128
+ other services reported for the same trace (see "Following a request across services" below).
129
+
130
+ Register `forgeOpsTrackerTracingExpressMiddleware` before your routes, or call
131
+ `registerForgeOpsTrackerTracing(app)` on Fastify (see "Distributed tracing" below), for these to be
132
+ set on an error you capture yourself inside a handler; Fastify knows the route before any handler
133
+ runs, Express as soon as the matched handler starts. `forgeOpsTrackerExpressMiddleware` and
134
+ `registerForgeOpsTracker` add them to an unhandled error on their own. They're left out entirely
135
+ outside a request (a script, a worker), and they're never PII-scrubbed, like `environment` and
136
+ `release`: they're structured fields, not free text.
137
+
116
138
  ## Identifying users
117
139
 
118
140
  ```js
@@ -355,7 +377,7 @@ batch behind it. Requires a ForgeOps plan that includes custom metrics / infrast
355
377
 
356
378
  ## Distributed tracing
357
379
 
358
- For one slow request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
380
+ For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
359
381
  `registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
360
382
  call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under
361
383
  it, so ForgeOps can render a waterfall for that one request.
@@ -363,7 +385,7 @@ it, so ForgeOps can render a waterfall for that one request.
363
385
  ```js
364
386
  import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
365
387
 
366
- app.use(forgeOpsTrackerTracingExpressMiddleware);
388
+ app.use(forgeOpsTrackerTracingExpressMiddleware); // before your routes
367
389
  ```
368
390
 
369
391
  ```js
@@ -375,7 +397,9 @@ registerForgeOpsTrackerTracing(fastify);
375
397
  This is the whole point of the feature, so it's worth being explicit about: a request's own trace
376
398
  is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided
377
399
  entirely client-side before a single byte goes over the wire. A normal, fast request costs
378
- nothing extra.
400
+ nothing extra. A request that errored (an unhandled error, or any `captureException()` during it)
401
+ is the one exception: its trace is always sent, however fast it was, since the spans leading up to
402
+ an error are exactly what you want next to it.
379
403
 
380
404
  ```js
381
405
  forgeOpsTracker.init({
@@ -413,6 +437,58 @@ with `trackTracing` off: it just runs the callback and records nothing, never th
413
437
  Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured
414
438
  trace is simply rejected server-side and dropped, exactly like any other delivery failure.
415
439
 
440
+ ### Following a request across services
441
+
442
+ Traces use the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard, so a request can
443
+ be followed from one service into the next, whichever language or tracing library the other
444
+ service uses:
445
+
446
+ - **Incoming**: a request that arrives with a valid `traceparent` header continues that trace (same
447
+ trace id), and its root span records the caller's span as its parent. A missing or malformed
448
+ header just starts a new trace.
449
+ - **Outgoing**: every outbound call made through Node's `http`/`https` modules during a request
450
+ gets a `traceparent` header whose parent id is that call's own span, so the next service's spans
451
+ nest under it. A `traceparent` you set yourself is never replaced, and this client's own
452
+ deliveries to ForgeOps never get one. The global `fetch()` doesn't go through `http`/`https`, so
453
+ it isn't instrumented and gets no header.
454
+
455
+ A trace id exists for every request even with `trackTracing` off, and the header is still sent,
456
+ since the trace id is also what links an error here to an error in the service you called. Seeing
457
+ the two errors connected in ForgeOps needs both services' projects linked there.
458
+
459
+ ```js
460
+ import http from "node:http";
461
+ import express from "express";
462
+ import * as forgeOpsTracker from "@forge-ops/tracker";
463
+ import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
464
+ import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
465
+
466
+ forgeOpsTracker.init({
467
+ dsn: "https://<api_key>@getforgeops.net/api/v1/events",
468
+ propagateTraces: true, // default true; false never sends traceparent
469
+ // Default null: every host. A string matches that host and its subdomains on a dot boundary
470
+ // ("internal.example" matches "orders.internal.example", not "notinternal.example"); a RegExp is
471
+ // tested against the host, without the port.
472
+ tracePropagationTargets: ["internal.example", /^10\.0\./],
473
+ });
474
+
475
+ const app = express();
476
+ app.use(forgeOpsTrackerTracingExpressMiddleware);
477
+
478
+ app.post("/checkout", (req, res, next) => {
479
+ // Carries traceparent: 00-<this request's trace id>-<this call's span id>-01
480
+ const call = http.request("http://orders.internal.example/orders", { method: "POST" }, (response) => {
481
+ response.resume();
482
+ res.status(response.statusCode === 201 ? 201 : 502).end();
483
+ });
484
+ call.on("error", next);
485
+ call.end(JSON.stringify({ sku: "A1" }));
486
+ });
487
+
488
+ app.use(forgeOpsTrackerExpressMiddleware);
489
+ app.listen(3000);
490
+ ```
491
+
416
492
  **Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
417
493
  of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
418
494
  here either); a database call or Redis call inside a traced request just won't show up as its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to ForgeOps over HTTP.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -116,6 +116,26 @@ export class Configuration {
116
116
  * gems/forge_ops_tracker's own Configuration#trace_capture_threshold_ms default. */
117
117
  traceCaptureThresholdMs = 1000;
118
118
 
119
+ /**
120
+ * Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
121
+ * http/https request made during a request the Express/Fastify tracing integration is handling
122
+ * (see httpTracing.js), so a service that reports to ForgeOps too (or anything else that
123
+ * understands the standard) continues this request's trace instead of starting its own. On by
124
+ * default, the same as gems/forge_ops_tracker's propagate_traces: the header also carries the
125
+ * trace id that links an error here to an error there, which is useful with or without spans.
126
+ * Never overwrites a traceparent the request already has.
127
+ */
128
+ propagateTraces = true;
129
+ /**
130
+ * Which hosts get that header. null (the default) means every host. Otherwise an array whose
131
+ * entries are either a host string, matching that host and its subdomains on a dot boundary
132
+ * ("example.com" matches "api.example.com", not "badexample.com"; a leading dot is ignored), or a
133
+ * RegExp tested against the host (no port). Narrow it for a third-party API that rejects headers
134
+ * it doesn't know, or that shouldn't learn this app's trace ids at all.
135
+ * @type {Array<string | RegExp> | null}
136
+ */
137
+ tracePropagationTargets = null;
138
+
119
139
  /** @returns {string | null} */
120
140
  apiKey() {
121
141
  const parsed = this.#parsedDsn();
@@ -203,6 +223,51 @@ export class Configuration {
203
223
  return uri.replace(/\/events$/, "/spans");
204
224
  }
205
225
 
226
+ /**
227
+ * Whether an outbound request to `host` should carry a traceparent header; see
228
+ * propagateTraces/tracePropagationTargets above. Case-insensitive, since hostnames are.
229
+ * @param {string} host
230
+ * @returns {boolean}
231
+ */
232
+ propagatesTraceTo(host) {
233
+ if (!this.propagateTraces) {
234
+ return false;
235
+ }
236
+ if (this.tracePropagationTargets === null || this.tracePropagationTargets === undefined) {
237
+ return true;
238
+ }
239
+ const normalized = String(host ?? "").toLowerCase();
240
+ return this.tracePropagationTargets.some((target) => {
241
+ if (target instanceof RegExp) {
242
+ target.lastIndex = 0; // a /g or /y pattern would otherwise carry state from the last test
243
+ return target.test(normalized);
244
+ }
245
+ const suffix = String(target).toLowerCase().replace(/^\./, "");
246
+ return suffix !== "" && (normalized === suffix || normalized.endsWith(`.${suffix}`));
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Whether a request to `host`:`port` is one of this client's own deliveries to ForgeOps (same
252
+ * host and port as the DSN), so the outbound-HTTP wrapper never adds a traceparent to it. The
253
+ * Client itself uses fetch(), which that wrapper never sees, so this is a second guard, not the
254
+ * only one.
255
+ * @param {string} host
256
+ * @param {number | string | null | undefined} port
257
+ * @param {string} protocol "http:" or "https:"
258
+ * @returns {boolean}
259
+ */
260
+ isOwnHost(host, port, protocol) {
261
+ const parsed = this.#parsedDsn();
262
+ if (parsed === null || !host) {
263
+ return false;
264
+ }
265
+ const defaultPort = (scheme) => (scheme === "http:" ? "80" : scheme === "https:" ? "443" : "");
266
+ const ownPort = parsed.port || defaultPort(parsed.protocol);
267
+ const targetPort = port === null || port === undefined || port === "" ? defaultPort(protocol) : String(port);
268
+ return String(host).toLowerCase() === parsed.hostname.toLowerCase() && targetPort === ownPort;
269
+ }
270
+
206
271
  /** @returns {boolean} */
207
272
  isEnabled() {
208
273
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -49,8 +49,14 @@ export class EventBuilder {
49
49
  * @param {Record<string, unknown>} [context]
50
50
  * @param {Record<string, unknown> | null} [user]
51
51
  * @param {Array<Record<string, unknown>>} [breadcrumbs]
52
+ * @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request]
53
+ * Where the error happened (see RequestState in requestState.js): transactionName is the same
54
+ * "GET /orders/:id" name the request's root span and performance samples use, endpoint the
55
+ * human-readable route ("GET /orders/:id"), traceId the request's 32-character lowercase hex
56
+ * W3C trace id, which is also what links this error to errors other services reported for the
57
+ * same trace. All absent outside a request, and a missing one is left out of the payload.
52
58
  */
53
- build(error, context = {}, user = null, breadcrumbs = []) {
59
+ build(error, context = {}, user = null, breadcrumbs = [], request = {}) {
54
60
  const payload = {
55
61
  exception_class: error?.name ?? "Error",
56
62
  message: error?.message ?? "",
@@ -69,13 +75,24 @@ export class EventBuilder {
69
75
  if (breadcrumbs && breadcrumbs.length > 0) {
70
76
  payload.breadcrumbs = [...breadcrumbs];
71
77
  }
78
+ if (request.transactionName) {
79
+ payload.transaction_name = request.transactionName;
80
+ }
81
+ if (request.endpoint) {
82
+ payload.endpoint = request.endpoint;
83
+ }
84
+ if (request.traceId) {
85
+ payload.trace_id = request.traceId;
86
+ }
72
87
  this.#attachSql(payload, error);
73
88
 
74
89
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
75
90
  }
76
91
 
77
- // exception_class/occurred_at/environment/release/server_name/user are
78
- // left alone: structured fields this client or the host app sets
92
+ // exception_class/occurred_at/environment/release/server_name/user/
93
+ // transaction_name/endpoint/trace_id are left alone (endpoint is a
94
+ // declared route pattern, never the literal path, so there's no id or
95
+ // token in it to scrub): structured fields this client or the host app sets
79
96
  // deliberately, not free text an exception or its context could
80
97
  // accidentally spill sensitive data into. user specifically is a
81
98
  // deliberate exemption, not an oversight: the scrubber's own email
@@ -1,5 +1,6 @@
1
1
  import http from "node:http";
2
2
  import https from "node:https";
3
+ import { generateSpanId, TRACEPARENT_HEADER } from "./traceParent.js";
3
4
 
4
5
  let installed = false;
5
6
  let originalHttpRequest = null;
@@ -20,19 +21,28 @@ let originalHttpsRequest = null;
20
21
  * recordSpan is passed in as a plain parameter rather than imported directly from index.js: this
21
22
  * module gets installed FROM index.js's own init(), so importing index.js back from here would
22
23
  * create a circular module dependency index.js has never needed before, for no real benefit over
23
- * just passing the one function actually used.
24
+ * just passing the functions actually used.
24
25
  *
25
- * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
26
+ * Also propagates the current request's trace to the service being called, as a W3C `traceparent`
27
+ * header (see traceParent.js, and Configuration#propagateTraces/tracePropagationTargets for turning
28
+ * it off or narrowing it to specific hosts). The header's parent id is this outbound call's own
29
+ * span id, generated before the call is made (the span itself can only be recorded afterward, once
30
+ * its duration is known) and then recorded with that exact id, so the downstream service's root
31
+ * span points at a span that really exists in this trace. traceparentFor decides whether there is
32
+ * a trace to continue at all (only inside a request) and returns null otherwise.
33
+ *
34
+ * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>, spanId?: string) => void} recordSpan
35
+ * @param {(host: string, port: number | string | null | undefined, protocol: string, spanId: string) => string | null} [traceparentFor]
26
36
  */
27
- export function installHttpTracing(recordSpan) {
37
+ export function installHttpTracing(recordSpan, traceparentFor = () => null) {
28
38
  if (installed) {
29
39
  return;
30
40
  }
31
41
  installed = true;
32
42
  originalHttpRequest = http.request;
33
43
  originalHttpsRequest = https.request;
34
- http.request = wrap(originalHttpRequest, recordSpan);
35
- https.request = wrap(originalHttpsRequest, recordSpan);
44
+ http.request = wrap(originalHttpRequest, recordSpan, traceparentFor, "http:");
45
+ https.request = wrap(originalHttpsRequest, recordSpan, traceparentFor, "https:");
36
46
  }
37
47
 
38
48
  /** @internal test-only: restores http/https.request exactly as installHttpTracing found them. */
@@ -47,12 +57,14 @@ export function _uninstallHttpTracing() {
47
57
  originalHttpsRequest = null;
48
58
  }
49
59
 
50
- function wrap(originalRequest, recordSpan) {
60
+ function wrap(originalRequest, recordSpan, traceparentFor, defaultProtocol) {
51
61
  return function patchedRequest(...args) {
62
+ const spanId = generateSpanId();
52
63
  const startedAt = new Date();
53
64
  const start = performance.now();
54
65
  const req = originalRequest.apply(this, args);
55
- const { method, host } = describeRequest(args);
66
+ const { method, host, port, protocol } = describeRequest(args, defaultProtocol);
67
+ propagateTrace(req, () => traceparentFor(host, port, protocol, spanId));
56
68
 
57
69
  // "response" and "error" are mutually exclusive on a real ClientRequest, but guarded anyway:
58
70
  // recording twice for the same outbound call would double it up in the waterfall.
@@ -62,7 +74,7 @@ function wrap(originalRequest, recordSpan) {
62
74
  return;
63
75
  }
64
76
  finished = true;
65
- recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status });
77
+ recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status }, spanId);
66
78
  };
67
79
 
68
80
  // No status at all means the request itself failed (DNS, connection refused, timeout) before
@@ -75,6 +87,31 @@ function wrap(originalRequest, recordSpan) {
75
87
  };
76
88
  }
77
89
 
90
+ /**
91
+ * Adds the traceparent header to a request that was just created and hasn't sent its headers yet
92
+ * (the caller only writes or ends it after http.request returns). Leaves a traceparent the caller
93
+ * already set alone: whoever set it explicitly knows better than this client which trace the call
94
+ * belongs to. Skipped when headers were already flushed (a raw-array `headers` option sends them at
95
+ * construction). Never throws, since a failure to add a header must never stop the host app's own
96
+ * request from going out.
97
+ *
98
+ * @param {import("node:http").ClientRequest} req
99
+ * @param {() => string | null} traceparent
100
+ */
101
+ function propagateTrace(req, traceparent) {
102
+ try {
103
+ if (req.headersSent || req.hasHeader(TRACEPARENT_HEADER)) {
104
+ return;
105
+ }
106
+ const value = traceparent();
107
+ if (value !== null) {
108
+ req.setHeader(TRACEPARENT_HEADER, value);
109
+ }
110
+ } catch {
111
+ // See this function's own comment: the request goes out without the header.
112
+ }
113
+ }
114
+
78
115
  /**
79
116
  * Best-effort parse of whatever calling convention produced this request (a bare options object,
80
117
  * a URL string/object plus an optional options object, either form optionally followed by a
@@ -82,15 +119,21 @@ function wrap(originalRequest, recordSpan) {
82
119
  * path or query string a customer's own request carries (that could hold a customer's own id or
83
120
  * a secret), the same reasoning every other transaction_name/span name in this client already
84
121
  * follows.
122
+ *
123
+ * port/protocol are only ever used to recognize this client's own deliveries to ForgeOps (see
124
+ * Configuration#isOwnHost), never in a label.
85
125
  * @param {unknown[]} args
126
+ * @param {string} defaultProtocol
86
127
  */
87
- function describeRequest(args) {
128
+ function describeRequest(args, defaultProtocol) {
88
129
  const [first, second] = args;
89
130
  const options = {};
90
131
 
91
132
  if (typeof first === "string" || first instanceof URL) {
92
133
  const url = typeof first === "string" ? new URL(first) : first;
93
134
  options.host = url.hostname;
135
+ options.port = url.port;
136
+ options.protocol = url.protocol;
94
137
  if (second && typeof second === "object") {
95
138
  Object.assign(options, second);
96
139
  }
@@ -100,5 +143,5 @@ function describeRequest(args) {
100
143
 
101
144
  const method = String(options.method ?? "GET").toUpperCase();
102
145
  const host = options.host ?? options.hostname ?? "unknown";
103
- return { method, host };
146
+ return { method, host, port: options.port, protocol: options.protocol ?? defaultProtocol };
104
147
  }
package/src/index.js CHANGED
@@ -8,8 +8,10 @@ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
8
8
  import { MetricBuffer } from "./metricBuffer.js";
9
9
  import { PerformanceFlusher } from "./performanceFlusher.js";
10
10
  import { Reporter } from "./reporter.js";
11
+ import { requestStateFor } from "./requestState.js";
11
12
  import { SessionFlusher } from "./sessionFlusher.js";
12
13
  import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
14
+ import { buildTraceparent } from "./traceParent.js";
13
15
 
14
16
  export { Configuration };
15
17
  export { withSql } from "./sqlStatement.js";
@@ -78,6 +80,14 @@ const spanStorage = new AsyncLocalStorage();
78
80
  // SpanBuffer at all" as the real signal for "not inside a traced request").
79
81
  const spanParentStorage = new AsyncLocalStorage();
80
82
 
83
+ // Request-scoped RequestState (trace id, remote parent span id, transaction name, endpoint, errored
84
+ // flag; see requestState.js), published by the Express/Fastify integrations for the duration of a
85
+ // request through _runWithRequestState below. Not gated on trackTracing: the trace id links an
86
+ // error to errors in other services even when no spans are ever recorded. Unset outside a request,
87
+ // where captureException, span tracing, and the outbound-HTTP wrapper all behave exactly as they
88
+ // did before this existed.
89
+ const requestStorage = new AsyncLocalStorage();
90
+
81
91
  function getConfiguration() {
82
92
  if (configuration === null) {
83
93
  configuration = new Configuration();
@@ -242,6 +252,64 @@ export function _runWithBreadcrumbTrail(callback) {
242
252
  return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
243
253
  }
244
254
 
255
+ /**
256
+ * Internal; called by the Express/Fastify integrations with their own request object and its
257
+ * incoming traceparent header's raw value. Returns the RequestState memoized on that request (see
258
+ * requestState.js), continuing the incoming trace or starting a fresh one, or null when the client
259
+ * isn't enabled at all, the same "skip entirely" gating every other automatic source applies.
260
+ *
261
+ * @param {object} request an Express req or a Fastify request
262
+ * @param {unknown} traceparent
263
+ * @returns {import("./requestState.js").RequestState | null}
264
+ */
265
+ export function _requestStateFor(request, traceparent) {
266
+ if (!getConfiguration().isEnabled()) {
267
+ return null;
268
+ }
269
+ return requestStateFor(request, traceparent);
270
+ }
271
+
272
+ /**
273
+ * Internal; runs callback with `state` published as the current request's for its whole async
274
+ * call chain. Just calls callback when state is null, or already the current one (two integrations
275
+ * wrapping the same request), so it never nests a second identical context.
276
+ *
277
+ * @template T
278
+ * @param {import("./requestState.js").RequestState | null} state
279
+ * @param {() => T} callback
280
+ * @returns {T}
281
+ */
282
+ export function _runWithRequestState(state, callback) {
283
+ if (!state || requestStorage.getStore() === state) {
284
+ return callback();
285
+ }
286
+ return requestStorage.run(state, callback);
287
+ }
288
+
289
+ /**
290
+ * Internal; called by the outbound-HTTP wrapper (httpTracing.js) just before a request to
291
+ * `host`:`port` goes out. Returns the traceparent header value naming `spanId` (that request's own
292
+ * span) as the parent, or null when this request shouldn't carry one: outside a request, with
293
+ * propagateTraces off or `host` not a propagation target, or for a delivery to ForgeOps itself.
294
+ *
295
+ * @param {string} host
296
+ * @param {number | string | null | undefined} port
297
+ * @param {string} protocol
298
+ * @param {string} spanId
299
+ * @returns {string | null}
300
+ */
301
+ export function _traceparentFor(host, port, protocol, spanId) {
302
+ const state = requestStorage.getStore();
303
+ if (!state) {
304
+ return null;
305
+ }
306
+ const config = getConfiguration();
307
+ if (config.isOwnHost(host, port, protocol) || !config.propagatesTraceTo(host)) {
308
+ return null;
309
+ }
310
+ return buildTraceparent(state.traceId, spanId);
311
+ }
312
+
245
313
  /**
246
314
  * Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
247
315
  * by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
@@ -261,7 +329,10 @@ export function _runWithSpanTrace(callback) {
261
329
  if (!config.trackTracing || !config.isEnabled()) {
262
330
  return callback();
263
331
  }
264
- const buffer = new SpanBuffer(config);
332
+ // The trace id and remote parent come from the request's RequestState (see _runWithRequestState
333
+ // above), so this trace and any error event from the same request agree on the trace.
334
+ const state = requestStorage.getStore();
335
+ const buffer = new SpanBuffer(config, state ? { traceId: state.traceId, parentSpanId: state.parentSpanId } : {});
265
336
  return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
266
337
  }
267
338
 
@@ -285,7 +356,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
285
356
  return;
286
357
  }
287
358
  buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
288
- if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs)) {
359
+ // An errored request's trace is always sent too, however fast it was, since the waterfall of
360
+ // what led up to an error is exactly what an issue page wants to show next to it. "Errored" means
361
+ // captureException ran during the request (the Express/Fastify error integrations call it too).
362
+ const errored = requestStorage.getStore()?.errored === true;
363
+ if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs) || (errored && buffer.rootRecorded)) {
289
364
  getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
290
365
  }
291
366
  }
@@ -303,15 +378,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
303
378
  * @param {Date} startedAt
304
379
  * @param {number} durationMs
305
380
  * @param {Record<string, unknown>} [data]
381
+ * @param {string} [spanId] only passed by the outbound-HTTP wrapper, which has to pick it before
382
+ * the call goes out so the traceparent header it sends names this exact span
306
383
  */
307
- export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
384
+ export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
308
385
  const config = getConfiguration();
309
386
  const buffer = spanStorage.getStore();
310
387
  if (!config.trackTracing || !buffer) {
311
388
  return;
312
389
  }
313
390
  const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
314
- buffer.record({ spanId: randomSpanId(), parentSpanId, name, kind, startedAt, durationMs, data });
391
+ buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
315
392
  }
316
393
 
317
394
  /**
@@ -363,7 +440,7 @@ export function init(options = {}) {
363
440
  // gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
364
441
  // config, checking configuration.trackTracing fresh on every actual outbound call instead (see
365
442
  // _recordSpan above), never at install time.
366
- installHttpTracing(_recordSpan);
443
+ installHttpTracing(_recordSpan, _traceparentFor);
367
444
 
368
445
  return config;
369
446
  }
@@ -373,13 +450,20 @@ export function init(options = {}) {
373
450
  * established for this async call chain, if anything; pass one explicitly to override that for
374
451
  * this one report.
375
452
  *
453
+ * Inside a request the Express/Fastify integrations are handling, the event also carries the
454
+ * request's transaction_name, endpoint, and trace_id, and the request's trace is sent however fast
455
+ * it was.
456
+ *
376
457
  * @param {Error} error
377
458
  * @param {Record<string, unknown>} [context]
378
459
  * @param {Record<string, unknown> | null} [user]
379
460
  */
380
461
  export function captureException(error, context = {}, user = null) {
381
462
  const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
382
- getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs);
463
+ const state = requestStorage.getStore();
464
+ state?.markErrored();
465
+ const request = state ? { transactionName: state.transactionName, endpoint: state.endpoint, traceId: state.traceId } : {};
466
+ getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs, request);
383
467
  }
384
468
 
385
469
  /**
@@ -565,6 +649,7 @@ export function _resetForTesting() {
565
649
  // sharing the same root context. disable() drops it and leaves the instance perfectly usable
566
650
  // again for the next run()/enterWith() call, confirmed directly, not assumed.
567
651
  breadcrumbStorage.disable();
652
+ requestStorage.disable();
568
653
  // Re-installed fresh on the very next init() call in whatever test runs next: without this,
569
654
  // http.request/https.request would stay wrapped by a closure over a *previous* test's own
570
655
  // _recordSpan reference forever (installHttpTracing's own guard only ever installs once per
@@ -1,4 +1,5 @@
1
- import { captureException, runWithUser } from "../index.js";
1
+ import { _requestStateFor, _runWithRequestState, captureException, runWithUser } from "../index.js";
2
+ import { describeExpressRoute } from "./tracing.js";
2
3
 
3
4
  /**
4
5
  * Express error-handling middleware. Register last, after all routes:
@@ -12,6 +13,12 @@ import { captureException, runWithUser } from "../index.js";
12
13
  * async route handlers to error-handling middleware automatically,
13
14
  * verified directly against a real async handler, not assumed. Only an
14
15
  * exception your own code catches and handles is invisible to this.
16
+ *
17
+ * The event carries the request's trace id, transaction name, and
18
+ * endpoint: the RequestState forgeOpsTrackerTracingExpressMiddleware
19
+ * already stashed on req when that's installed, or one created here from
20
+ * the request's own traceparent header when it isn't, so an unhandled
21
+ * error is linked across services either way.
15
22
  */
16
23
  export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
17
24
  // See integrations/sessionTracking.js's own comment: marks the request crashed before
@@ -20,10 +27,14 @@ export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
20
27
  // the time it checks.
21
28
  req._forgeOpsSessionCrashed = true;
22
29
 
23
- captureException(err, {
24
- path: req.path,
25
- method: req.method,
26
- });
30
+ const state = _requestStateFor(req, req.headers?.traceparent);
31
+ state?.describeWith(() => describeExpressRoute(req));
32
+ _runWithRequestState(state, () =>
33
+ captureException(err, {
34
+ path: req.path,
35
+ method: req.method,
36
+ }),
37
+ );
27
38
  next(err);
28
39
  }
29
40
 
@@ -1,4 +1,5 @@
1
- import { captureException } from "../index.js";
1
+ import { _requestStateFor, _runWithRequestState, captureException } from "../index.js";
2
+ import { fastifyRoutePattern } from "./tracing.js";
2
3
 
3
4
  /**
4
5
  * Called directly with your Fastify instance: not registered via
@@ -15,13 +16,25 @@ import { captureException } from "../index.js";
15
16
  * continues exactly as if this hook weren't registered. Only an
16
17
  * exception your own code catches and handles is invisible to this.
17
18
  *
19
+ * The event carries the request's trace id, transaction name, and
20
+ * endpoint: the RequestState registerForgeOpsTrackerTracing already
21
+ * created for this request when that's registered, or one created here
22
+ * from the request's own traceparent header when it isn't.
23
+ *
18
24
  * @param {import("fastify").FastifyInstance} fastify
19
25
  */
20
26
  export function registerForgeOpsTracker(fastify) {
21
27
  fastify.addHook("onError", async (request, reply, error) => {
22
- captureException(error, {
23
- path: request.url,
24
- method: request.method,
25
- });
28
+ const state = _requestStateFor(request, request.headers?.traceparent);
29
+ const route = fastifyRoutePattern(request);
30
+ if (state && route && state.transactionName === null) {
31
+ state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
32
+ }
33
+ _runWithRequestState(state, () =>
34
+ captureException(error, {
35
+ path: request.url,
36
+ method: request.method,
37
+ }),
38
+ );
26
39
  });
27
40
  }
@@ -1,4 +1,23 @@
1
- import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
1
+ import { _finishSpanTrace, _requestStateFor, _runWithRequestState, _runWithSpanTrace } from "../index.js";
2
+
3
+ /**
4
+ * How an Express request is named once a route has matched: transactionName the same
5
+ * "<HTTP method> <route pattern>" the root span and performance samples use, endpoint the same
6
+ * route pattern. req.route.path is the pattern as declared on the router that matched it; for a
7
+ * router mounted with app.use("/api", router), that's the part declared on the router, without
8
+ * "/api": req.baseUrl is the literal matched mount path, which for a parameterized mount
9
+ * ("/users/:userId") would put a real id into a field that must never carry one. null before a
10
+ * route has matched (the event then leaves both fields out).
11
+ *
12
+ * @param {import("express").Request} req
13
+ */
14
+ export function describeExpressRoute(req) {
15
+ if (!req.route) {
16
+ return null;
17
+ }
18
+ const name = `${req.method} ${req.route.path}`;
19
+ return { transactionName: name, endpoint: name };
20
+ }
2
21
 
3
22
  /**
4
23
  * Express distributed-tracing middleware: wraps the rest of the request in _runWithSpanTrace
@@ -20,20 +39,33 @@ import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
20
39
  * actually finished and the root span's real total duration is known: nothing about a normal,
21
40
  * fast request costs a single byte over the wire, matching gems/forge_ops_tracker's own
22
41
  * Middleware::SpanTracing.
42
+ *
43
+ * Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
44
+ * on, so every request gets a trace id, continuing an incoming W3C traceparent header when there
45
+ * is one (its root span's parent is then the caller's span), and an error captured anywhere in
46
+ * the request carries it plus the matched route. Register it before your routes for that: an
47
+ * error thrown by a route registered ahead of it never sees this request's trace. Express only
48
+ * sets req.route once the matched handler starts, so the route is read lazily, whenever an error
49
+ * needs it (see RequestState#describeWith).
23
50
  */
24
51
  export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
25
- _runWithSpanTrace(() => {
26
- const startedAt = new Date();
27
- const start = performance.now();
52
+ const state = _requestStateFor(req, req.headers?.traceparent);
53
+ state?.describeWith(() => describeExpressRoute(req));
54
+
55
+ _runWithRequestState(state, () =>
56
+ _runWithSpanTrace(() => {
57
+ const startedAt = new Date();
58
+ const start = performance.now();
28
59
 
29
- res.on("finish", () => {
30
- const durationMs = performance.now() - start;
31
- const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
32
- _finishSpanTrace(transactionName, startedAt, durationMs);
33
- });
60
+ res.on("finish", () => {
61
+ const durationMs = performance.now() - start;
62
+ const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
63
+ _finishSpanTrace(transactionName, startedAt, durationMs);
64
+ });
34
65
 
35
- next();
36
- });
66
+ next();
67
+ }),
68
+ );
37
69
  }
38
70
 
39
71
  /**
@@ -55,15 +87,29 @@ export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
55
87
  * matched a route at all (a 404), the same "pattern first, literal path as the 404 fallback"
56
88
  * shape the Express integration above already takes.
57
89
  *
90
+ * Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
91
+ * on, the same as the Express middleware above: a trace id for every request, continuing an
92
+ * incoming W3C traceparent header. Fastify has already matched the route by onRequest, so the
93
+ * transaction name and endpoint are set right here, before any handler runs, and only from the
94
+ * matched pattern: a request that matched no route (a 404) leaves both out.
95
+ *
58
96
  * @param {import("fastify").FastifyInstance} fastify
59
97
  */
60
98
  export function registerForgeOpsTrackerTracing(fastify) {
61
99
  fastify.addHook("onRequest", (request, reply, done) => {
62
- _runWithSpanTrace(() => {
63
- request._forgeOpsSpanStartedAt = new Date();
64
- request._forgeOpsSpanStart = performance.now();
65
- done();
66
- });
100
+ const state = _requestStateFor(request, request.headers?.traceparent);
101
+ const route = fastifyRoutePattern(request);
102
+ if (state && route) {
103
+ state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
104
+ }
105
+
106
+ _runWithRequestState(state, () =>
107
+ _runWithSpanTrace(() => {
108
+ request._forgeOpsSpanStartedAt = new Date();
109
+ request._forgeOpsSpanStart = performance.now();
110
+ done();
111
+ }),
112
+ );
67
113
  });
68
114
 
69
115
  fastify.addHook("onResponse", async (request) => {
@@ -78,3 +124,14 @@ export function registerForgeOpsTrackerTracing(fastify) {
78
124
  _finishSpanTrace(transactionName, request._forgeOpsSpanStartedAt, durationMs);
79
125
  });
80
126
  }
127
+
128
+ /**
129
+ * Fastify's matched route pattern ("/orders/:id", prefixes included), or undefined for a request
130
+ * that matched no route: request.routeOptions.url, falling back to the older request.routerPath.
131
+ *
132
+ * @param {import("fastify").FastifyRequest} request
133
+ * @returns {string | undefined}
134
+ */
135
+ export function fastifyRoutePattern(request) {
136
+ return request.routeOptions?.url ?? request.routerPath ?? undefined;
137
+ }
package/src/reporter.js CHANGED
@@ -22,14 +22,15 @@ export class Reporter {
22
22
  * @param {Record<string, unknown>} [context]
23
23
  * @param {Record<string, unknown> | null} [user]
24
24
  * @param {Array<Record<string, unknown>>} [breadcrumbs]
25
+ * @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request] see EventBuilder#build
25
26
  */
26
- report(error, context = {}, user = null, breadcrumbs = []) {
27
+ report(error, context = {}, user = null, breadcrumbs = [], request = {}) {
27
28
  try {
28
29
  if (!this.#configuration.isEnabled()) {
29
30
  return;
30
31
  }
31
32
 
32
- const payload = this.#eventBuilder.build(error, context, user, breadcrumbs);
33
+ const payload = this.#eventBuilder.build(error, context, user, breadcrumbs, request);
33
34
  this.#deliveryQueue.push(payload);
34
35
  } catch (e) {
35
36
  this.#configuration.log(`[forge-ops-tracker] report failed: ${e.name}: ${e.message}`);
@@ -0,0 +1,117 @@
1
+ import { generateTraceId, parseTraceparent } from "./traceParent.js";
2
+
3
+ /**
4
+ * Everything this client knows about the current request that isn't already owned by one of the
5
+ * older, single-purpose AsyncLocalStorage instances (the user, the breadcrumb trail, the span
6
+ * buffer): which trace it belongs to, which remote span called it (if any), what it's called, and
7
+ * whether it errored. One instance per request, published by the Express/Fastify integrations
8
+ * (see _runWithRequestState in index.js) so captureException, the tracing integration, and the
9
+ * outbound-HTTP wrapper can all reach it. Ported from
10
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/request_state.rb.
11
+ *
12
+ * The traceId exists for every request, not only when trackTracing is on: it's also what an error
13
+ * event carries so ForgeOps can link it to errors reported by other services taking part in the
14
+ * same trace, which works on any plan and with tracing off entirely.
15
+ *
16
+ * Also stashed on the framework's own request object (see requestStateFor), the Node equivalent of
17
+ * the Ruby gem memoizing it in the Rack env: an error-handling middleware that runs outside the
18
+ * tracing middleware's async context still finds the same instance there.
19
+ */
20
+ export class RequestState {
21
+ traceId;
22
+ parentSpanId;
23
+ errored = false;
24
+ #describe;
25
+ #transactionName = null;
26
+ #endpoint = null;
27
+
28
+ /**
29
+ * @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
30
+ */
31
+ constructor({ traceId = generateTraceId(), parentSpanId = null } = {}) {
32
+ this.traceId = traceId;
33
+ this.parentSpanId = parentSpanId;
34
+ }
35
+
36
+ /**
37
+ * Continues the caller's trace when the request arrived with a usable W3C traceparent header,
38
+ * remembering the caller's span as this request's remote parent; starts a fresh trace otherwise.
39
+ * @param {unknown} traceparent
40
+ */
41
+ static fromTraceparent(traceparent) {
42
+ const incoming = parseTraceparent(traceparent);
43
+ return incoming === null ? new RequestState() : new RequestState(incoming);
44
+ }
45
+
46
+ /**
47
+ * How to name this request, read lazily, each time an error needs it: Express only sets req.route
48
+ * once the matched route's own handler starts running, with no app-level hook in between, so the
49
+ * integration hands over a function that reads it then instead of a name it can't know yet at
50
+ * request start. Returns null (and the event leaves both fields out) until a route has matched.
51
+ * @param {() => { transactionName: string, endpoint: string } | null} describe
52
+ */
53
+ describeWith(describe) {
54
+ this.#describe = describe;
55
+ }
56
+
57
+ /**
58
+ * Sets both names outright, for a framework that already knows the matched route when the
59
+ * request starts (Fastify's onRequest).
60
+ * @param {string} transactionName
61
+ * @param {string} endpoint
62
+ */
63
+ setRoute(transactionName, endpoint) {
64
+ this.#transactionName = transactionName;
65
+ this.#endpoint = endpoint;
66
+ }
67
+
68
+ /** @returns {string | null} */
69
+ get transactionName() {
70
+ return this.#transactionName ?? this.#described()?.transactionName ?? null;
71
+ }
72
+
73
+ /** @returns {string | null} */
74
+ get endpoint() {
75
+ return this.#endpoint ?? this.#described()?.endpoint ?? null;
76
+ }
77
+
78
+ markErrored() {
79
+ this.errored = true;
80
+ }
81
+
82
+ #described() {
83
+ try {
84
+ return this.#describe?.() ?? null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ }
90
+
91
+ const REQUEST_STATE_KEY = Symbol.for("forge-ops-tracker.requestState");
92
+
93
+ /**
94
+ * The RequestState memoized on `request` (an Express req or a Fastify request), created from its
95
+ * traceparent header on first use: whichever integration reaches a request first creates it, and
96
+ * every other one gets the identical instance back, so they all agree on one trace id. A symbol
97
+ * key, not a plain property: invisible to the app's own enumeration and serialization of req.
98
+ *
99
+ * @param {object} request
100
+ * @param {unknown} traceparent the incoming traceparent header's raw value
101
+ * @returns {RequestState}
102
+ */
103
+ export function requestStateFor(request, traceparent) {
104
+ if (request[REQUEST_STATE_KEY] === undefined) {
105
+ request[REQUEST_STATE_KEY] = RequestState.fromTraceparent(traceparent);
106
+ }
107
+ return request[REQUEST_STATE_KEY];
108
+ }
109
+
110
+ /**
111
+ * The RequestState already memoized on `request`, if any integration created one.
112
+ * @param {object} request
113
+ * @returns {RequestState | undefined}
114
+ */
115
+ export function existingRequestState(request) {
116
+ return request?.[REQUEST_STATE_KEY];
117
+ }
package/src/spanBuffer.js CHANGED
@@ -1,15 +1,15 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { generateSpanId, generateTraceId } from "./traceParent.js";
2
2
 
3
3
  /**
4
- * A short, unique-enough identifier for one span: 8 random bytes as hex, matching
5
- * gems/forge_ops_tracker/lib/forge_ops_tracker/span_buffer.rb's own SecureRandom.hex(8). The
4
+ * A short, unique-enough identifier for one span: a W3C span id (16 lowercase hex characters,
5
+ * never all zeros; see traceParent.js), the same id a traceparent header's parent-id carries. The
6
6
  * server only ever needs these to be unique within one trace's own array (see
7
7
  * Api::V1::SpansController on the Rails side), never a real database id, so this is deliberately
8
8
  * cheap rather than a full UUID.
9
9
  * @returns {string}
10
10
  */
11
11
  export function randomSpanId() {
12
- return randomBytes(8).toString("hex");
12
+ return generateSpanId();
13
13
  }
14
14
 
15
15
  /**
@@ -33,17 +33,31 @@ export class SpanBuffer {
33
33
  rootSpanId;
34
34
  spans = [];
35
35
  #rootDurationMs = null;
36
+ #remoteParentSpanId;
36
37
 
37
- constructor(configuration) {
38
+ /**
39
+ * traceId/parentSpanId come from the request's own state (see _runWithRequestState in index.js)
40
+ * when there is one, never generated separately here, so a trace this sends and an error event
41
+ * from the same request always agree on which trace they belong to. parentSpanId is the calling
42
+ * service's span (from an incoming traceparent header), recorded as the root span's parent: the
43
+ * server treats a parent that isn't in this trace's own batch as remote, and nests this
44
+ * service's root under the caller's outgoing HTTP span.
45
+ *
46
+ * @param {import("./configuration.js").Configuration} configuration
47
+ * @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
48
+ */
49
+ constructor(configuration, { traceId = generateTraceId(), parentSpanId = null } = {}) {
38
50
  this.#configuration = configuration;
39
- this.traceId = randomBytes(16).toString("hex");
51
+ this.traceId = traceId;
40
52
  this.rootSpanId = randomSpanId();
53
+ this.#remoteParentSpanId = parentSpanId;
41
54
  }
42
55
 
43
56
  /**
44
57
  * root: true only for the one call that records the request's own root span: forces
45
- * parent_span_id null explicitly, the same reasoning span_buffer.rb's own #record documents for
46
- * why that can't just be read off of "whatever's currently open" for the root specifically.
58
+ * parent_span_id to the remote parent (null unless the request arrived with a traceparent
59
+ * header) explicitly, the same reasoning span_buffer.rb's own #record documents for why that
60
+ * can't just be read off of "whatever's currently open" for the root specifically.
47
61
  *
48
62
  * @param {{
49
63
  * spanId: string,
@@ -59,7 +73,7 @@ export class SpanBuffer {
59
73
  record({ spanId, parentSpanId = null, name, kind, startedAt, durationMs, data = {}, root = false }) {
60
74
  this.spans.push({
61
75
  span_id: spanId,
62
- parent_span_id: root ? null : parentSpanId,
76
+ parent_span_id: root ? this.#remoteParentSpanId : parentSpanId,
63
77
  name,
64
78
  kind,
65
79
  // Date#toISOString() already yields millisecond precision UTC ("...sssZ"), exactly the wire
@@ -76,6 +90,15 @@ export class SpanBuffer {
76
90
  }
77
91
  }
78
92
 
93
+ /**
94
+ * Whether the request's own root span has been recorded: an errored request's trace is only sent
95
+ * once it has, since spans with no root to hang from would render as a waterfall with no top.
96
+ * @returns {boolean}
97
+ */
98
+ get rootRecorded() {
99
+ return this.#rootDurationMs !== null;
100
+ }
101
+
79
102
  /**
80
103
  * null (never sent) until the root span has actually been recorded: a request whose own
81
104
  * tracing integration never got the chance to call back at all has no duration to compare
@@ -0,0 +1,88 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Reads and writes the W3C Trace Context `traceparent` header
5
+ * (https://www.w3.org/TR/trace-context/), the vendor-neutral format for carrying one trace across
6
+ * service boundaries: `00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>`. Used in both
7
+ * directions: the Express/Fastify integrations parse an incoming one so a request continues the
8
+ * caller's trace instead of starting its own, and httpTracing.js builds an outgoing one so the next
9
+ * service along continues this request's. Ported from
10
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/trace_parent.rb.
11
+ *
12
+ * Deliberately strict on the way in, the same posture the spec itself asks receivers to take: a
13
+ * malformed value, uppercase hex, the reserved version "ff", or an all-zero trace/parent id are all
14
+ * treated as "no usable header at all" (parseTraceparent returns null and the request starts a
15
+ * fresh trace), never half-trusted. A version this client doesn't know yet is still accepted as
16
+ * long as its first four fields have version 00's shape, which is exactly what the spec says a
17
+ * version-00 parser should do with a future version; version 00 itself must have exactly four
18
+ * fields.
19
+ */
20
+
21
+ export const TRACEPARENT_HEADER = "traceparent";
22
+
23
+ const PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/s;
24
+ const INVALID_TRACE_ID = "0".repeat(32);
25
+ const INVALID_PARENT_ID = "0".repeat(16);
26
+
27
+ // Always "01" (sampled) on the way out: this client decides whether to actually send a trace only
28
+ // once the request is over (see _finishSpanTrace in index.js), long after this header has already
29
+ // gone out on an outbound call, so there's no honest earlier answer to give than "this may be
30
+ // recorded." A downstream service is free to make its own decision either way.
31
+ const SAMPLED_FLAGS = "01";
32
+
33
+ /**
34
+ * @param {unknown} value an incoming header's raw value
35
+ * @returns {{ traceId: string, parentSpanId: string } | null} null for anything that isn't a usable header
36
+ */
37
+ export function parseTraceparent(value) {
38
+ if (typeof value !== "string") {
39
+ return null;
40
+ }
41
+ const match = PATTERN.exec(value.trim());
42
+ if (match === null) {
43
+ return null;
44
+ }
45
+ const [, version, traceId, parentSpanId, , rest] = match;
46
+ if (version === "ff" || (version === "00" && rest !== undefined)) {
47
+ return null;
48
+ }
49
+ if (traceId === INVALID_TRACE_ID || parentSpanId === INVALID_PARENT_ID) {
50
+ return null;
51
+ }
52
+ return { traceId, parentSpanId };
53
+ }
54
+
55
+ /**
56
+ * @param {string} traceId
57
+ * @param {string} spanId
58
+ * @returns {string}
59
+ */
60
+ export function buildTraceparent(traceId, spanId) {
61
+ return `00-${traceId}-${spanId}-${SAMPLED_FLAGS}`;
62
+ }
63
+
64
+ /**
65
+ * 32 lowercase hex characters, the W3C trace-id format, never all zeros (the spec reserves that as
66
+ * invalid, and a receiver drops the whole header over it): vanishingly unlikely from 16 random
67
+ * bytes, but free to rule out.
68
+ * @returns {string}
69
+ */
70
+ export function generateTraceId() {
71
+ return randomId(16);
72
+ }
73
+
74
+ /**
75
+ * 16 lowercase hex characters, the W3C parent-id (span id) format, never all zeros.
76
+ * @returns {string}
77
+ */
78
+ export function generateSpanId() {
79
+ return randomId(8);
80
+ }
81
+
82
+ function randomId(bytes) {
83
+ let id = randomBytes(bytes).toString("hex");
84
+ while (/^0+$/.test(id)) {
85
+ id = randomBytes(bytes).toString("hex");
86
+ }
87
+ return id;
88
+ }