@forge-ops/tracker 0.3.0 → 0.9.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.
@@ -0,0 +1,104 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ let installed = false;
5
+ let originalHttpRequest = null;
6
+ let originalHttpsRequest = null;
7
+
8
+ /**
9
+ * Wraps Node's own http/https request() exactly once for the life of the process (idempotent, see
10
+ * the installed guard below): most third-party HTTP clients (axios, node-fetch, undici's own
11
+ * legacy adapter, ...) ultimately call through one of these two functions, so wrapping here
12
+ * covers those as a side effect without a separate integration per library, the same reasoning
13
+ * gems/forge_ops_tracker's own Net::HTTP.prepend(Timing) documents for Ruby's own HTTP ecosystem.
14
+ * Known, honest gap this shares with that Ruby wrapper's own module-prepend approach: code that
15
+ * destructures `request` off `http`/`https` (or otherwise captures a reference) *before*
16
+ * installHttpTracing runs keeps calling the original, unwrapped function; calling init() before
17
+ * any other library gets required, the normal way an app entry point is written anyway, avoids
18
+ * this in practice.
19
+ *
20
+ * recordSpan is passed in as a plain parameter rather than imported directly from index.js: this
21
+ * module gets installed FROM index.js's own init(), so importing index.js back from here would
22
+ * 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
+ *
25
+ * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
26
+ */
27
+ export function installHttpTracing(recordSpan) {
28
+ if (installed) {
29
+ return;
30
+ }
31
+ installed = true;
32
+ originalHttpRequest = http.request;
33
+ originalHttpsRequest = https.request;
34
+ http.request = wrap(originalHttpRequest, recordSpan);
35
+ https.request = wrap(originalHttpsRequest, recordSpan);
36
+ }
37
+
38
+ /** @internal test-only: restores http/https.request exactly as installHttpTracing found them. */
39
+ export function _uninstallHttpTracing() {
40
+ if (!installed) {
41
+ return;
42
+ }
43
+ http.request = originalHttpRequest;
44
+ https.request = originalHttpsRequest;
45
+ installed = false;
46
+ originalHttpRequest = null;
47
+ originalHttpsRequest = null;
48
+ }
49
+
50
+ function wrap(originalRequest, recordSpan) {
51
+ return function patchedRequest(...args) {
52
+ const startedAt = new Date();
53
+ const start = performance.now();
54
+ const req = originalRequest.apply(this, args);
55
+ const { method, host } = describeRequest(args);
56
+
57
+ // "response" and "error" are mutually exclusive on a real ClientRequest, but guarded anyway:
58
+ // recording twice for the same outbound call would double it up in the waterfall.
59
+ let finished = false;
60
+ const finish = (status) => {
61
+ if (finished) {
62
+ return;
63
+ }
64
+ finished = true;
65
+ recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status });
66
+ };
67
+
68
+ // No status at all means the request itself failed (DNS, connection refused, timeout) before
69
+ // any response ever came back: recorded regardless, an honest "this dependency call never
70
+ // completed" span rather than silently dropping it.
71
+ req.once("response", (res) => finish(res.statusCode));
72
+ req.once("error", () => finish());
73
+
74
+ return req;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Best-effort parse of whatever calling convention produced this request (a bare options object,
80
+ * a URL string/object plus an optional options object, either form optionally followed by a
81
+ * callback): only ever used to build a low-cardinality "<METHOD> <host>" label, never to read the
82
+ * path or query string a customer's own request carries (that could hold a customer's own id or
83
+ * a secret), the same reasoning every other transaction_name/span name in this client already
84
+ * follows.
85
+ * @param {unknown[]} args
86
+ */
87
+ function describeRequest(args) {
88
+ const [first, second] = args;
89
+ const options = {};
90
+
91
+ if (typeof first === "string" || first instanceof URL) {
92
+ const url = typeof first === "string" ? new URL(first) : first;
93
+ options.host = url.hostname;
94
+ if (second && typeof second === "object") {
95
+ Object.assign(options, second);
96
+ }
97
+ } else if (first && typeof first === "object") {
98
+ Object.assign(options, first);
99
+ }
100
+
101
+ const method = String(options.method ?? "GET").toUpperCase();
102
+ const host = options.host ?? options.hostname ?? "unknown";
103
+ return { method, host };
104
+ }
package/src/index.js CHANGED
@@ -1,10 +1,15 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
1
3
  import { Client } from "./client.js";
2
4
  import { Configuration } from "./configuration.js";
3
5
  import { DeliveryQueue } from "./deliveryQueue.js";
4
6
  import { EventBuilder } from "./eventBuilder.js";
7
+ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
8
+ import { MetricBuffer } from "./metricBuffer.js";
5
9
  import { PerformanceFlusher } from "./performanceFlusher.js";
6
10
  import { Reporter } from "./reporter.js";
7
11
  import { SessionFlusher } from "./sessionFlusher.js";
12
+ import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
8
13
 
9
14
  export { Configuration };
10
15
 
@@ -12,10 +17,66 @@ let configuration = null;
12
17
  let reporter = null;
13
18
  let sessionFlusher = null;
14
19
  let performanceFlusher = null;
20
+ let spanQueue = null;
21
+ let metricBuffer = null;
22
+ let infrastructureMetricBuffer = null;
15
23
  let processHandlersInstalled = false;
16
24
  let uncaughtExceptionListener = null;
17
25
  let unhandledRejectionListener = null;
18
26
 
27
+ // Request-scoped affected-user storage. AsyncLocalStorage, not a plain module-level variable the
28
+ // way gems/forge_ops_tracker uses Thread.current or Python uses threading.local: Node is
29
+ // single-threaded, so a plain variable would leak across concurrent requests being interleaved
30
+ // on the same event loop, exactly the bug those other languages' own thread-local choice avoids
31
+ // for their own concurrency model. AsyncLocalStorage is the Node-idiomatic equivalent, but its
32
+ // API shape is different on purpose: it only propagates a value through an async call chain that
33
+ // was explicitly wrapped via runWithUser() below (there's no imperative "just set it from
34
+ // anywhere" the way Thread.current allows), which is why this SDK's own set_user-equivalent is a
35
+ // wrapping function, not a bare setter.
36
+ const userStorage = new AsyncLocalStorage();
37
+
38
+ // Request-scoped breadcrumb trail storage. A second, independent AsyncLocalStorage instance
39
+ // rather than folding this into userStorage above: distinct concern (an ordered trail, not a
40
+ // single attached value), same "own storage, own wrapping function" shape
41
+ // gems/forge_ops_tracker's own Thread.current[:forge_ops_tracker_breadcrumbs] (separate from
42
+ // Thread.current[:forge_ops_tracker_current_user]) and Python's own separate _breadcrumbs
43
+ // ContextVar (separate from its threading.local .user) both already take.
44
+ //
45
+ // Unlike Ruby's Thread.current and Python's contextvars.ContextVar, AsyncLocalStorage.run()
46
+ // needs no explicit "clear it when the request ends" step (see BreadcrumbContext's own
47
+ // ensure-clear, or _end_breadcrumb_trail's own reset()): once the callback passed to run()
48
+ // finishes (synchronously or, for everything causally descended from it, asynchronously), the
49
+ // store automatically stops being visible to anything outside that call chain, exactly the
50
+ // property AsyncLocalStorage exists to provide. That's why _runWithBreadcrumbTrail below is a
51
+ // single wrapping call, the same shape runWithUser already has, rather than a separate
52
+ // start/end pair the way Ruby's middleware or Python's Django/Flask/Celery hooks need (those
53
+ // frameworks fire two genuinely separate hooks, "request started" and "request ended," with no
54
+ // single function this client could wrap around the whole thing; Express's next()-based
55
+ // middleware chain and a real Fastify onRequest hook wrapping its own `done` callback both do
56
+ // give this client that single continuous call chain to wrap, confirmed directly below, not
57
+ // assumed).
58
+ const breadcrumbStorage = new AsyncLocalStorage();
59
+
60
+ // Request-scoped span-tracing storage: one SpanBuffer per request (see spanBuffer.js), the same
61
+ // per-request AsyncLocalStorage scoping breadcrumbStorage already uses above and for the identical
62
+ // reason (Node interleaves many concurrent requests' async callbacks on one thread, so anything
63
+ // less than AsyncLocalStorage would leak one request's spans into another's).
64
+ const spanStorage = new AsyncLocalStorage();
65
+
66
+ // A second, nested AsyncLocalStorage tracks "the currently open span" (a span_id, not a whole
67
+ // object) separately from the buffer itself: span() below and every leaf-span recorder read this
68
+ // to find their own real parent, and span() enters a fresh nested context around its own callback
69
+ // to make itself the parent for anything recorded further inside. This is deliberately NOT a
70
+ // mutable stack on SpanBuffer the way gems/forge_ops_tracker's own Ruby buffer uses (correct for
71
+ // Ruby's single-threaded-per-request execution, wrong here): two span() calls awaited
72
+ // concurrently in the same request (Promise.all rather than one after another) each get their own,
73
+ // correctly-scoped view of "what's currently open" for free, the exact property nested
74
+ // AsyncLocalStorage.run() calls already provide, rather than one call's pop() corrupting the
75
+ // other's still-pending parent. Defaults to the request's own root span id once _runWithSpanTrace
76
+ // below establishes it; genuinely unset outside of that (span()/_recordSpan both treat "no
77
+ // SpanBuffer at all" as the real signal for "not inside a traced request").
78
+ const spanParentStorage = new AsyncLocalStorage();
79
+
19
80
  function getConfiguration() {
20
81
  if (configuration === null) {
21
82
  configuration = new Configuration();
@@ -49,6 +110,83 @@ function getPerformanceFlusher() {
49
110
  return performanceFlusher;
50
111
  }
51
112
 
113
+ /** The two metric buffers, created together on first use: independent of each other (a script may only ever call one), but cheap enough that creating both is simpler than tracking which. */
114
+ function getMetricBuffers() {
115
+ if (metricBuffer === null) {
116
+ const config = getConfiguration();
117
+ const client = new Client(config);
118
+ metricBuffer = new MetricBuffer(config, (entries) => client.deliverMetrics(entries), () => config.metricFlushIntervalMs);
119
+ infrastructureMetricBuffer = new MetricBuffer(
120
+ config,
121
+ (entries) => client.deliverInfrastructureMetrics(entries),
122
+ () => config.infrastructureMetricFlushIntervalMs,
123
+ );
124
+ }
125
+ return { metrics: metricBuffer, infrastructure: infrastructureMetricBuffer };
126
+ }
127
+
128
+ /**
129
+ * Records a named business metric (a signup, a payment, anything you want to name), buffered and
130
+ * flushed periodically as one batch rather than one network call per capture. `value` defaults to
131
+ * 1 so a bare counter-style call needs no argument; pass one for a real magnitude
132
+ * (`captureMetric("payment", 49)`); it may be negative (a refund). A no-op when the client isn't
133
+ * enabled (no DSN, or this environment isn't in `enabledEnvironments`), and a NaN or infinite value
134
+ * is dropped.
135
+ *
136
+ * @param {string} name
137
+ * @param {number} [value]
138
+ */
139
+ export function captureMetric(name, value = 1) {
140
+ const config = getConfiguration();
141
+ if (!config.isEnabled()) {
142
+ return;
143
+ }
144
+ getMetricBuffers().metrics.record({ metric_name: name, value, environment: config.environment, release: config.release });
145
+ }
146
+
147
+ /**
148
+ * Records one infrastructure reading (CPU, memory, disk, anything else a script of yours reads)
149
+ * from one of your own hosts. `hostname` defaults to `Configuration#serverName`, so a script running
150
+ * on the box it reports about needs no argument. Same buffered-batch delivery and no-op-when-disabled
151
+ * contract as captureMetric. A short-lived cron script needs nothing more: the buffer also flushes
152
+ * when the event loop drains ("beforeExit"); call `await flushMetrics()` if it exits another way
153
+ * (`process.exit()`).
154
+ *
155
+ * @param {string} name
156
+ * @param {number} value
157
+ * @param {{ hostname?: string }} [options]
158
+ */
159
+ export function captureInfrastructureMetric(name, value, options = {}) {
160
+ const config = getConfiguration();
161
+ if (!config.isEnabled()) {
162
+ return;
163
+ }
164
+ getMetricBuffers().infrastructure.record({ metric_name: name, value, hostname: options.hostname ?? config.serverName });
165
+ }
166
+
167
+ /** Delivers every buffered metric and infrastructure reading right now, instead of waiting for the next flush interval. */
168
+ export async function flushMetrics() {
169
+ if (metricBuffer === null) {
170
+ return;
171
+ }
172
+ await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
173
+ }
174
+
175
+ /**
176
+ * A DeliveryQueue reused for spans (see that class's own comment for why it takes a
177
+ * deliverMethod/label rather than needing a whole second, near-identical class the way
178
+ * gems/forge_ops_tracker's own SpanQueue is): one whole trace is one queue entry, delivered
179
+ * promptly, never batched together with other traces the way PerformanceFlusher/the error
180
+ * DeliveryQueue's own default usage batch or accumulate over a time window.
181
+ */
182
+ function getSpanQueue() {
183
+ if (spanQueue === null) {
184
+ const config = getConfiguration();
185
+ spanQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverSpans", label: "trace" });
186
+ }
187
+ return spanQueue;
188
+ }
189
+
52
190
  /**
53
191
  * Internal; called by the Express/Fastify session-tracking integrations, never by host app
54
192
  * code directly (there's nothing for a caller to decide here beyond what the middleware/hook
@@ -79,6 +217,125 @@ export function _recordPerformance(transactionName, durationMs) {
79
217
  getPerformanceFlusher().record(transactionName, durationMs);
80
218
  }
81
219
 
220
+ /**
221
+ * Internal; called by the Express/Fastify breadcrumb-context integrations, never by host app
222
+ * code directly. Gives `callback`'s own async call chain a fresh, empty breadcrumb trail to
223
+ * accumulate into (via addBreadcrumb() below, or whatever automatic source records into it
224
+ * alongside its own timing; see integrations/performance.js), the same per-request scoping
225
+ * gems/forge_ops_tracker's BreadcrumbContext middleware and Python's _start_breadcrumb_trail
226
+ * both provide for their own concurrency model. A no-op wrapper (still calls callback, just
227
+ * with no buffer set up) when trackBreadcrumbs is off or the client isn't enabled at all, the
228
+ * same "skip entirely" gating _recordPerformance/_recordSession already apply to their own
229
+ * automatic sources; addBreadcrumb() below still works regardless of this, by lazily creating
230
+ * its own standalone buffer.
231
+ *
232
+ * @template T
233
+ * @param {() => T} callback
234
+ * @returns {T}
235
+ */
236
+ export function _runWithBreadcrumbTrail(callback) {
237
+ const config = getConfiguration();
238
+ if (!config.trackBreadcrumbs || !config.isEnabled()) {
239
+ return callback();
240
+ }
241
+ return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
242
+ }
243
+
244
+ /**
245
+ * Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
246
+ * by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
247
+ * own root span id already established as "currently open" in spanParentStorage (see that
248
+ * storage's own comment above), so a leaf span or a manual span() call recorded before the root
249
+ * span itself is ever actually recorded still parents onto it correctly. A no-op wrapper (still
250
+ * calls callback, just with no buffer set up) when trackTracing is off or the client isn't
251
+ * enabled at all, the same "skip entirely" gating every other automatic source in this file
252
+ * already applies to itself.
253
+ *
254
+ * @template T
255
+ * @param {() => T} callback
256
+ * @returns {T}
257
+ */
258
+ export function _runWithSpanTrace(callback) {
259
+ const config = getConfiguration();
260
+ if (!config.trackTracing || !config.isEnabled()) {
261
+ return callback();
262
+ }
263
+ const buffer = new SpanBuffer(config);
264
+ return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
265
+ }
266
+
267
+ /**
268
+ * Internal; called by the Express/Fastify tracing integration once the request has actually
269
+ * finished and the root span's own real total duration is finally known (not any sooner: that
270
+ * duration is what the slow/fast decision below is actually made against). Records the request's
271
+ * own root "controller" span, then, only now and entirely client-side, decides whether the whole
272
+ * trace crossed configuration.traceCaptureThresholdMs and is worth sending at all: a normal, fast
273
+ * request's buffer is simply discarded here, unsent, the entire reason this feature never costs a
274
+ * fast request a single byte over the wire. Mirrors
275
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/middleware/span_tracing.rb's own ensure block.
276
+ *
277
+ * @param {string} name
278
+ * @param {Date} startedAt
279
+ * @param {number} durationMs
280
+ */
281
+ export function _finishSpanTrace(name, startedAt, durationMs) {
282
+ const buffer = spanStorage.getStore();
283
+ if (!buffer) {
284
+ return;
285
+ }
286
+ buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
287
+ if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs)) {
288
+ getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Internal; called by whatever this client already automatically times end-to-end with no
294
+ * children of its own to nest anything under (currently just the outbound HTTP wrapper; see
295
+ * httpTracing.js), never by host app code directly. A no-op, the same as every other automatic
296
+ * source, when there's no request-level trace currently open at all (trackTracing off, or
297
+ * genuinely outside any request the tracing integration ever wrapped): never fabricates a trace
298
+ * with no request to belong to.
299
+ *
300
+ * @param {string} name
301
+ * @param {string} kind
302
+ * @param {Date} startedAt
303
+ * @param {number} durationMs
304
+ * @param {Record<string, unknown>} [data]
305
+ */
306
+ export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
307
+ const config = getConfiguration();
308
+ const buffer = spanStorage.getStore();
309
+ if (!config.trackTracing || !buffer) {
310
+ return;
311
+ }
312
+ const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
313
+ buffer.record({ spanId: randomSpanId(), parentSpanId, name, kind, startedAt, durationMs, data });
314
+ }
315
+
316
+ /**
317
+ * Internal; called by whatever this client already automatically times (currently just the
318
+ * Express controller/route lifecycle; see integrations/performance.js), never by host app code
319
+ * directly, same reasoning as _recordPerformance above. Independent of trackPerformance: an app
320
+ * could want the trail without the timing data, or vice versa, and each call site already has
321
+ * to check its own flag regardless, so there's no real cost to keeping the two independent
322
+ * rather than tying breadcrumbs to whether performance monitoring happens to be on (mirrors
323
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/railtie.rb's own reasoning for the same design
324
+ * choice).
325
+ *
326
+ * @param {string} message
327
+ * @param {string} category
328
+ * @param {string} [level]
329
+ * @param {Record<string, unknown>} [data]
330
+ */
331
+ export function _recordBreadcrumb(message, category, level = "info", data = {}) {
332
+ const config = getConfiguration();
333
+ if (!config.trackBreadcrumbs || !config.isEnabled()) {
334
+ return;
335
+ }
336
+ addBreadcrumb(message, { category, level, data });
337
+ }
338
+
82
339
  /**
83
340
  * Configure the client. Call once at startup.
84
341
  *
@@ -100,17 +357,145 @@ export function init(options = {}) {
100
357
  installProcessLevelHandlers();
101
358
  }
102
359
 
360
+ // Independent of installProcessHandlers above (that flag is only about the two process-level
361
+ // listeners): installed unconditionally, every init() call, the same way
362
+ // gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
363
+ // config, checking configuration.trackTracing fresh on every actual outbound call instead (see
364
+ // _recordSpan above), never at install time.
365
+ installHttpTracing(_recordSpan);
366
+
103
367
  return config;
104
368
  }
105
369
 
106
370
  /**
107
- * Report an exception you've already caught.
371
+ * Report an exception you've already caught. `user` defaults to whatever runWithUser() below
372
+ * established for this async call chain, if anything; pass one explicitly to override that for
373
+ * this one report.
108
374
  *
109
375
  * @param {Error} error
110
376
  * @param {Record<string, unknown>} [context]
377
+ * @param {Record<string, unknown> | null} [user]
378
+ */
379
+ export function captureException(error, context = {}, user = null) {
380
+ const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
381
+ getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs);
382
+ }
383
+
384
+ /**
385
+ * Adds one breadcrumb to the current request's trail (see _runWithBreadcrumbTrail above for how
386
+ * a request gets its own buffer to start with), or, outside a request entirely (a plain script,
387
+ * a worker with no breadcrumb-context wrapping installed at all), this call's own standalone
388
+ * trail for the rest of the process's root async context. Lazily creates a buffer the first
389
+ * time it's needed on whatever AsyncLocalStorage context is currently active, the same "works
390
+ * standalone, no specific setup required" shape gems/forge_ops_tracker's own
391
+ * ForgeOpsTracker.add_breadcrumb and Python's forge_ops_tracker.add_breadcrumb both already have.
392
+ *
393
+ * Uses breadcrumbStorage.enterWith() rather than .run() for that lazy-creation path
394
+ * specifically: unlike _runWithBreadcrumbTrail above, there's no callback here to wrap (this
395
+ * function returns void, called from arbitrary places in host app code), so .run() has nothing
396
+ * to scope the new buffer to. enterWith() sets the store for the remainder of whatever async
397
+ * chain is currently executing, which is exactly the "persists for the rest of this thread"
398
+ * behavior Ruby's Thread.current[:x] ||= ... and Python's ContextVar.set() both give their own
399
+ * manual API for free; the one deliberate difference is test isolation, see
400
+ * _resetForTesting's own comment below for why that needs an explicit disable().
401
+ *
402
+ * Works whether or not trackBreadcrumbs is on: that flag only gates the automatic sources
403
+ * _recordBreadcrumb above records, never this manual call, matching
404
+ * gems/forge_ops_tracker's own ForgeOpsTracker.add_breadcrumb.
405
+ *
406
+ * @param {string} message
407
+ * @param {{ category?: string, level?: string, data?: Record<string, unknown> }} [options]
408
+ */
409
+ export function addBreadcrumb(message, options = {}) {
410
+ let buffer = breadcrumbStorage.getStore();
411
+ if (buffer === undefined) {
412
+ buffer = new BreadcrumbBuffer(getConfiguration());
413
+ breadcrumbStorage.enterWith(buffer);
414
+ }
415
+ buffer.add(message, options);
416
+ }
417
+
418
+ /**
419
+ * Runs `callback` (sync or async) with an affected user attached to every `captureException()`
420
+ * call made anywhere in its call chain that doesn't pass its own explicit `user`, e.g. from your
421
+ * own middleware:
422
+ *
423
+ * app.use((req, res, next) => runWithUser({ id: req.user?.id }, next));
424
+ *
425
+ * There's no imperative `setUser()` the way other languages in this repo have: AsyncLocalStorage
426
+ * (see this module's own comment on `userStorage`) only propagates a value through an async call
427
+ * chain that was explicitly wrapped like this, so wrapping is the correct, idiomatic shape here,
428
+ * not a limitation being worked around.
429
+ *
430
+ * @template T
431
+ * @param {Record<string, unknown>} user
432
+ * @param {() => T} callback
433
+ * @returns {T}
434
+ */
435
+ export function runWithUser(user, callback) {
436
+ return userStorage.run({ user }, callback);
437
+ }
438
+
439
+ /**
440
+ * Wraps a block of your own service-layer code as a manual span, e.g.
441
+ *
442
+ * await forgeOpsTracker.span("PaymentService.charge", async () => { ... });
443
+ *
444
+ * There's no automatic way to detect "this is a logically distinct service layer" the way a
445
+ * database query or an outbound HTTP call already has a real instrumentation hook to extend, the
446
+ * same gap gems/forge_ops_tracker's own ForgeOpsTracker.span exists to fill. Works with a sync or
447
+ * async callback transparently, the same way runWithUser() above does: callback's own return
448
+ * value (or settled promise) is what this returns, with duration measured either immediately for
449
+ * a sync callback or once its promise settles for an async one, never forcing a synchronous
450
+ * caller through an extra microtask the way an always-`async function span` would.
451
+ *
452
+ * Outside any request-level trace (trackTracing off, or genuinely outside a request the
453
+ * Express/Fastify tracing integration ever wrapped), just runs callback and records nothing:
454
+ * unlike addBreadcrumb, this does NOT lazily create its own standalone buffer, matching
455
+ * gems/forge_ops_tracker's own ForgeOpsTracker.span exactly, since a lone span with no
456
+ * request-level trace around it, and no middleware left running to ever flush it, would just
457
+ * accumulate in memory forever with nothing to ever send it, worse than not recording it at all.
458
+ *
459
+ * @template T
460
+ * @param {string} name
461
+ * @param {() => T} callback
462
+ * @param {{ kind?: string, data?: Record<string, unknown> }} [options]
463
+ * @returns {T}
111
464
  */
112
- export function captureException(error, context = {}) {
113
- getReporter().report(error, context);
465
+ export function span(name, callback, { kind = "service", data = {} } = {}) {
466
+ const buffer = spanStorage.getStore();
467
+ if (!buffer) {
468
+ return callback();
469
+ }
470
+
471
+ const spanId = randomSpanId();
472
+ const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
473
+ const startedAt = new Date();
474
+ const start = performance.now();
475
+ const finish = () => {
476
+ buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs: performance.now() - start, data });
477
+ };
478
+
479
+ try {
480
+ const result = spanParentStorage.run(spanId, callback);
481
+ if (result && typeof result.then === "function") {
482
+ return result.then(
483
+ (value) => {
484
+ finish();
485
+ return value;
486
+ },
487
+ (error) => {
488
+ finish();
489
+ throw error;
490
+ },
491
+ );
492
+ }
493
+ finish();
494
+ return result;
495
+ } catch (error) {
496
+ finish();
497
+ throw error;
498
+ }
114
499
  }
115
500
 
116
501
  /**
@@ -163,7 +548,28 @@ export function _resetForTesting() {
163
548
  reporter = null;
164
549
  sessionFlusher = null;
165
550
  performanceFlusher = null;
551
+ spanQueue = null;
552
+ metricBuffer?.discard();
553
+ infrastructureMetricBuffer?.discard();
554
+ metricBuffer = null;
555
+ infrastructureMetricBuffer = null;
166
556
  processHandlersInstalled = false;
167
557
  uncaughtExceptionListener = null;
168
558
  unhandledRejectionListener = null;
559
+ // disable(), not just leaving it alone: a test that calls addBreadcrumb() standalone (outside
560
+ // any _runWithBreadcrumbTrail wrapping, exercising the lazy-creation path on purpose) sets the
561
+ // store via enterWith() on whatever async context Node's test runner happens to be executing
562
+ // tests from, which, unlike a scoped run() call, has no natural point where it unwinds on its
563
+ // own. Without this, that leftover store could otherwise bleed into a later, unrelated test
564
+ // sharing the same root context. disable() drops it and leaves the instance perfectly usable
565
+ // again for the next run()/enterWith() call, confirmed directly, not assumed.
566
+ breadcrumbStorage.disable();
567
+ // Re-installed fresh on the very next init() call in whatever test runs next: without this,
568
+ // http.request/https.request would stay wrapped by a closure over a *previous* test's own
569
+ // _recordSpan reference forever (installHttpTracing's own guard only ever installs once per
570
+ // process otherwise), which happens to still work correctly here since _recordSpan itself reads
571
+ // module state fresh on every call rather than closing over anything test-specific, but relying
572
+ // on that instead of actually restoring the original functions would be fragile, not a genuine
573
+ // guarantee.
574
+ _uninstallHttpTracing();
169
575
  }
@@ -0,0 +1,70 @@
1
+ import { _runWithBreadcrumbTrail } from "../index.js";
2
+
3
+ /**
4
+ * Gives every request a fresh, empty breadcrumb trail to accumulate into (via
5
+ * addBreadcrumb(), or whatever automatic source records into it alongside its own timing; see
6
+ * integrations/performance.js), so one request's trail never bleeds into another's, the same
7
+ * concern gems/forge_ops_tracker's own Middleware::BreadcrumbContext and Python's
8
+ * _start_breadcrumb_trail/_end_breadcrumb_trail both solve for their own concurrency model. A
9
+ * sibling to the session-tracking/user-context/performance integrations below, not folded into
10
+ * any of them: distinct concern, same "own file, own registration point" shape every integration
11
+ * in this client already takes.
12
+ *
13
+ * Register FIRST, before forgeOpsTrackerUserContextMiddleware and
14
+ * forgeOpsTrackerPerformanceExpressMiddleware (and, for the same reason, before
15
+ * forgeOpsTrackerSessionTrackingExpressMiddleware too): every one of those can record a
16
+ * breadcrumb of its own or run host app code that calls addBreadcrumb(), so the trail has to
17
+ * already exist by the time any of them runs, exactly why gems/forge_ops_tracker's own
18
+ * initializer ordering comment documents the identical requirement for its Rack middleware.
19
+ *
20
+ * app.use(forgeOpsTrackerBreadcrumbContextExpressMiddleware); // first, before everything else
21
+ * app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
22
+ * app.use(forgeOpsTrackerPerformanceExpressMiddleware);
23
+ * app.use(forgeOpsTrackerUserContextMiddleware);
24
+ * // ...routes...
25
+ * app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
26
+ *
27
+ * Wraps `next` in _runWithBreadcrumbTrail (see that function's own doc comment in index.js for
28
+ * why this needs no explicit "clear" step the way Ruby's ensure or Python's finally do): confirmed
29
+ * directly, with a real Express app and a real async route handler, that a breadcrumb recorded
30
+ * from deep inside an awaited route handler (or from the performance middleware's own
31
+ * res.on("finish") listener, which fires later still) is visible to
32
+ * forgeOpsTrackerExpressMiddleware's own captureException() call at the end of the same request,
33
+ * the same "AsyncLocalStorage survives all the way through, not just synchronously" property
34
+ * forgeOpsTrackerUserContextMiddleware's own comment already confirms for the user-context case.
35
+ */
36
+ export function forgeOpsTrackerBreadcrumbContextExpressMiddleware(req, res, next) {
37
+ _runWithBreadcrumbTrail(next);
38
+ }
39
+
40
+ /**
41
+ * Fastify equivalent, added as an onRequest hook (Fastify's earliest request-lifecycle hook,
42
+ * firing before routing and before any other hook this client registers) rather than exported as
43
+ * a plain function to call directly, mirroring registerForgeOpsTracker in integrations/fastify.js:
44
+ *
45
+ * registerForgeOpsTrackerBreadcrumbContext(app); // first, before registerForgeOpsTracker
46
+ *
47
+ * A real design question Ruby's/Python's implementations don't answer for Node, since Fastify has
48
+ * no single function this client could wrap the way Express's next() or Ruby's app.call(env) both
49
+ * let a middleware wrap "the rest of this request" in one call: Fastify's request lifecycle is a
50
+ * sequence of independently-invoked hooks instead. The fix is _runWithBreadcrumbTrail(done): an
51
+ * onRequest hook declared with the callback-style (request, reply, done) signature, wrapping done
52
+ * itself (Fastify's own continuation, which advances to the next hook/handler) in
53
+ * AsyncLocalStorage.run(). Node's async_hooks propagate context by causality, not by lexical
54
+ * nesting, so the context established here is visible to everything Fastify goes on to invoke as
55
+ * a consequence of calling done(): the route handler, and (should it throw) the onError hook this
56
+ * client's own integrations/fastify.js registers. Confirmed directly against a real Fastify app,
57
+ * a real async route handler, and two genuinely concurrent, interleaved requests (one slower than
58
+ * the other, so their event-loop turns actually interleave, not just run back-to-back): each
59
+ * request's own trail stayed correctly isolated from the other's, and each one's onError hook saw
60
+ * only its own request's breadcrumbs, never the other's, exactly the property AsyncLocalStorage
61
+ * exists to provide and exactly what needed confirming for a hook-based framework rather than
62
+ * assuming it carries over unchanged from the Express case above.
63
+ *
64
+ * @param {import("fastify").FastifyInstance} fastify
65
+ */
66
+ export function registerForgeOpsTrackerBreadcrumbContext(fastify) {
67
+ fastify.addHook("onRequest", (request, reply, done) => {
68
+ _runWithBreadcrumbTrail(done);
69
+ });
70
+ }