@forge-ops/tracker 0.10.0 → 0.12.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/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
3
+ import { buildChange, buildSnapshot } from "./changes.js";
3
4
  import { Client } from "./client.js";
4
5
  import { Configuration } from "./configuration.js";
5
6
  import { DeliveryQueue } from "./deliveryQueue.js";
@@ -8,8 +9,10 @@ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
8
9
  import { MetricBuffer } from "./metricBuffer.js";
9
10
  import { PerformanceFlusher } from "./performanceFlusher.js";
10
11
  import { Reporter } from "./reporter.js";
12
+ import { requestStateFor } from "./requestState.js";
11
13
  import { SessionFlusher } from "./sessionFlusher.js";
12
14
  import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
15
+ import { buildTraceparent } from "./traceParent.js";
13
16
 
14
17
  export { Configuration };
15
18
  export { withSql } from "./sqlStatement.js";
@@ -19,6 +22,8 @@ let reporter = null;
19
22
  let sessionFlusher = null;
20
23
  let performanceFlusher = null;
21
24
  let spanQueue = null;
25
+ let changeQueue = null;
26
+ let changeSnapshotStarted = false;
22
27
  let metricBuffer = null;
23
28
  let infrastructureMetricBuffer = null;
24
29
  let processHandlersInstalled = false;
@@ -78,6 +83,14 @@ const spanStorage = new AsyncLocalStorage();
78
83
  // SpanBuffer at all" as the real signal for "not inside a traced request").
79
84
  const spanParentStorage = new AsyncLocalStorage();
80
85
 
86
+ // Request-scoped RequestState (trace id, remote parent span id, transaction name, endpoint, errored
87
+ // flag; see requestState.js), published by the Express/Fastify integrations for the duration of a
88
+ // request through _runWithRequestState below. Not gated on trackTracing: the trace id links an
89
+ // error to errors in other services even when no spans are ever recorded. Unset outside a request,
90
+ // where captureException, span tracing, and the outbound-HTTP wrapper all behave exactly as they
91
+ // did before this existed.
92
+ const requestStorage = new AsyncLocalStorage();
93
+
81
94
  function getConfiguration() {
82
95
  if (configuration === null) {
83
96
  configuration = new Configuration();
@@ -173,6 +186,70 @@ export async function flushMetrics() {
173
186
  await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
174
187
  }
175
188
 
189
+ function getChangeQueue() {
190
+ if (changeQueue === null) {
191
+ const config = getConfiguration();
192
+ changeQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverChange", label: "change" });
193
+ }
194
+ return changeQueue;
195
+ }
196
+
197
+ /**
198
+ * Records one thing that changed in a running system, so ForgeOps can show it next to the errors
199
+ * and slowdowns that followed:
200
+ *
201
+ * recordChange({ kind: "feature_flag", title: "Enabled new_checkout for 10%", details: { rollout: 10 } });
202
+ *
203
+ * `kind` is one of feature_flag, config, migration, dependency, infrastructure, or other (anything
204
+ * else is sent as "other"). `environment` defaults to the configured one; `occurredAt` (a Date or an
205
+ * ISO 8601 string) defaults to now; `id` is an optional idempotency key. Queued and delivered on the
206
+ * same async loop as error events, so it never blocks the caller. A no-op when the client isn't
207
+ * enabled. Never throws: returns false when nothing was queued.
208
+ *
209
+ * @param {{ kind: string, title: string, details?: Record<string, unknown>, environment?: string,
210
+ * service?: string, actor?: string, url?: string, id?: string, occurredAt?: Date | string }} change
211
+ * @returns {boolean}
212
+ */
213
+ export function recordChange(change) {
214
+ try {
215
+ const config = getConfiguration();
216
+ if (!config.isEnabled()) {
217
+ return false;
218
+ }
219
+ const payload = buildChange(config, change ?? {});
220
+ if (payload === null) {
221
+ return false;
222
+ }
223
+ return getChangeQueue().push(payload);
224
+ } catch (e) {
225
+ getConfiguration().log(`[forge-ops-tracker] recordChange failed: ${e.name}: ${e.message}`);
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Sends the one startup snapshot this process sends (see changes.js), on a later event-loop turn so
232
+ * init() returns immediately and startup never waits on it. A second call is a no-op. Does nothing,
233
+ * and doesn't use up the once, when the client isn't enabled or detectChanges is off. The timer is
234
+ * unref'd, so a short script that's otherwise done doesn't stay alive just to send it. Never throws.
235
+ */
236
+ function startChangeSnapshot(config) {
237
+ if (changeSnapshotStarted || !config.isEnabled() || !config.detectChanges) {
238
+ return false;
239
+ }
240
+ changeSnapshotStarted = true;
241
+
242
+ const timer = setTimeout(async () => {
243
+ try {
244
+ await new Client(config).deliverChangeSnapshot(await buildSnapshot(config));
245
+ } catch (e) {
246
+ config.log(`[forge-ops-tracker] change snapshot failed: ${e.name}: ${e.message}`);
247
+ }
248
+ }, 0);
249
+ timer.unref?.();
250
+ return true;
251
+ }
252
+
176
253
  /**
177
254
  * A DeliveryQueue reused for spans (see that class's own comment for why it takes a
178
255
  * deliverMethod/label rather than needing a whole second, near-identical class the way
@@ -242,6 +319,64 @@ export function _runWithBreadcrumbTrail(callback) {
242
319
  return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
243
320
  }
244
321
 
322
+ /**
323
+ * Internal; called by the Express/Fastify integrations with their own request object and its
324
+ * incoming traceparent header's raw value. Returns the RequestState memoized on that request (see
325
+ * requestState.js), continuing the incoming trace or starting a fresh one, or null when the client
326
+ * isn't enabled at all, the same "skip entirely" gating every other automatic source applies.
327
+ *
328
+ * @param {object} request an Express req or a Fastify request
329
+ * @param {unknown} traceparent
330
+ * @returns {import("./requestState.js").RequestState | null}
331
+ */
332
+ export function _requestStateFor(request, traceparent) {
333
+ if (!getConfiguration().isEnabled()) {
334
+ return null;
335
+ }
336
+ return requestStateFor(request, traceparent);
337
+ }
338
+
339
+ /**
340
+ * Internal; runs callback with `state` published as the current request's for its whole async
341
+ * call chain. Just calls callback when state is null, or already the current one (two integrations
342
+ * wrapping the same request), so it never nests a second identical context.
343
+ *
344
+ * @template T
345
+ * @param {import("./requestState.js").RequestState | null} state
346
+ * @param {() => T} callback
347
+ * @returns {T}
348
+ */
349
+ export function _runWithRequestState(state, callback) {
350
+ if (!state || requestStorage.getStore() === state) {
351
+ return callback();
352
+ }
353
+ return requestStorage.run(state, callback);
354
+ }
355
+
356
+ /**
357
+ * Internal; called by the outbound-HTTP wrapper (httpTracing.js) just before a request to
358
+ * `host`:`port` goes out. Returns the traceparent header value naming `spanId` (that request's own
359
+ * span) as the parent, or null when this request shouldn't carry one: outside a request, with
360
+ * propagateTraces off or `host` not a propagation target, or for a delivery to ForgeOps itself.
361
+ *
362
+ * @param {string} host
363
+ * @param {number | string | null | undefined} port
364
+ * @param {string} protocol
365
+ * @param {string} spanId
366
+ * @returns {string | null}
367
+ */
368
+ export function _traceparentFor(host, port, protocol, spanId) {
369
+ const state = requestStorage.getStore();
370
+ if (!state) {
371
+ return null;
372
+ }
373
+ const config = getConfiguration();
374
+ if (config.isOwnHost(host, port, protocol) || !config.propagatesTraceTo(host)) {
375
+ return null;
376
+ }
377
+ return buildTraceparent(state.traceId, spanId);
378
+ }
379
+
245
380
  /**
246
381
  * Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
247
382
  * by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
@@ -261,7 +396,10 @@ export function _runWithSpanTrace(callback) {
261
396
  if (!config.trackTracing || !config.isEnabled()) {
262
397
  return callback();
263
398
  }
264
- const buffer = new SpanBuffer(config);
399
+ // The trace id and remote parent come from the request's RequestState (see _runWithRequestState
400
+ // above), so this trace and any error event from the same request agree on the trace.
401
+ const state = requestStorage.getStore();
402
+ const buffer = new SpanBuffer(config, state ? { traceId: state.traceId, parentSpanId: state.parentSpanId } : {});
265
403
  return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
266
404
  }
267
405
 
@@ -285,7 +423,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
285
423
  return;
286
424
  }
287
425
  buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
288
- if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs)) {
426
+ // An errored request's trace is always sent too, however fast it was, since the waterfall of
427
+ // what led up to an error is exactly what an issue page wants to show next to it. "Errored" means
428
+ // captureException ran during the request (the Express/Fastify error integrations call it too).
429
+ const errored = requestStorage.getStore()?.errored === true;
430
+ if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs) || (errored && buffer.rootRecorded)) {
289
431
  getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
290
432
  }
291
433
  }
@@ -303,15 +445,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
303
445
  * @param {Date} startedAt
304
446
  * @param {number} durationMs
305
447
  * @param {Record<string, unknown>} [data]
448
+ * @param {string} [spanId] only passed by the outbound-HTTP wrapper, which has to pick it before
449
+ * the call goes out so the traceparent header it sends names this exact span
306
450
  */
307
- export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
451
+ export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
308
452
  const config = getConfiguration();
309
453
  const buffer = spanStorage.getStore();
310
454
  if (!config.trackTracing || !buffer) {
311
455
  return;
312
456
  }
313
457
  const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
314
- buffer.record({ spanId: randomSpanId(), parentSpanId, name, kind, startedAt, durationMs, data });
458
+ buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
315
459
  }
316
460
 
317
461
  /**
@@ -363,7 +507,9 @@ export function init(options = {}) {
363
507
  // gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
364
508
  // config, checking configuration.trackTracing fresh on every actual outbound call instead (see
365
509
  // _recordSpan above), never at install time.
366
- installHttpTracing(_recordSpan);
510
+ installHttpTracing(_recordSpan, _traceparentFor);
511
+
512
+ startChangeSnapshot(config);
367
513
 
368
514
  return config;
369
515
  }
@@ -373,13 +519,20 @@ export function init(options = {}) {
373
519
  * established for this async call chain, if anything; pass one explicitly to override that for
374
520
  * this one report.
375
521
  *
522
+ * Inside a request the Express/Fastify integrations are handling, the event also carries the
523
+ * request's transaction_name, endpoint, and trace_id, and the request's trace is sent however fast
524
+ * it was.
525
+ *
376
526
  * @param {Error} error
377
527
  * @param {Record<string, unknown>} [context]
378
528
  * @param {Record<string, unknown> | null} [user]
379
529
  */
380
530
  export function captureException(error, context = {}, user = null) {
381
531
  const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
382
- getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs);
532
+ const state = requestStorage.getStore();
533
+ state?.markErrored();
534
+ const request = state ? { transactionName: state.transactionName, endpoint: state.endpoint, traceId: state.traceId } : {};
535
+ getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs, request);
383
536
  }
384
537
 
385
538
  /**
@@ -537,8 +690,13 @@ function installProcessLevelHandlers() {
537
690
  process.on("unhandledRejection", unhandledRejectionListener);
538
691
  }
539
692
 
540
- /** @internal not part of the public API: resets module state between test cases */
541
- export function _resetForTesting() {
693
+ /**
694
+ * @internal not part of the public API: resets module state between test cases. The startup change
695
+ * snapshot is left marked as already sent, so the many tests that init() an enabled client and
696
+ * count fetch calls don't see an extra one; pass { changeSnapshot: true } to let the next init()
697
+ * send it (see test/changes.test.js).
698
+ */
699
+ export function _resetForTesting({ changeSnapshot = false } = {}) {
542
700
  if (uncaughtExceptionListener) {
543
701
  process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
544
702
  }
@@ -550,6 +708,8 @@ export function _resetForTesting() {
550
708
  sessionFlusher = null;
551
709
  performanceFlusher = null;
552
710
  spanQueue = null;
711
+ changeQueue = null;
712
+ changeSnapshotStarted = !changeSnapshot;
553
713
  metricBuffer?.discard();
554
714
  infrastructureMetricBuffer?.discard();
555
715
  metricBuffer = null;
@@ -565,6 +725,7 @@ export function _resetForTesting() {
565
725
  // sharing the same root context. disable() drops it and leaves the instance perfectly usable
566
726
  // again for the next run()/enterWith() call, confirmed directly, not assumed.
567
727
  breadcrumbStorage.disable();
728
+ requestStorage.disable();
568
729
  // Re-installed fresh on the very next init() call in whatever test runs next: without this,
569
730
  // http.request/https.request would stay wrapped by a closure over a *previous* test's own
570
731
  // _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
+ }