@forge-ops/tracker 0.5.0 → 0.9.1

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
@@ -1,6 +1,6 @@
1
1
  # @forge-ops/tracker
2
2
 
3
- Node.js error reporting client for a [ForgeOps](../../) instance.
3
+ Node.js error reporting client for [ForgeOps](https://getforgeops.net).
4
4
  Requires Node 18+ (for global `fetch`). It captures uncaught exceptions, unhandled promise
5
5
  rejections, and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers
6
6
  events to ForgeOps over HTTP without blocking the request or process that raised them.
@@ -20,7 +20,7 @@ variable or explicitly:
20
20
  import * as forgeOpsTracker from "@forge-ops/tracker";
21
21
 
22
22
  forgeOpsTracker.init({
23
- dsn: "https://<api_key>@your-forgeops-host/api/v1/events", // or leave unset to read FORGE_OPS_DSN
23
+ dsn: "https://<api_key>@getforgeops.net/api/v1/events", // or leave unset to read FORGE_OPS_DSN
24
24
  release: "...",
25
25
  environment: "production",
26
26
  });
@@ -32,11 +32,13 @@ overridden by passing it in the options object.
32
32
  ### Express
33
33
 
34
34
  ```js
35
+ import { forgeOpsTrackerBreadcrumbContextExpressMiddleware } from "@forge-ops/tracker/integrations/breadcrumb-context";
35
36
  import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
36
37
  import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
37
38
  import { forgeOpsTrackerUserContextMiddleware, forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
38
39
 
39
- app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
40
+ app.use(forgeOpsTrackerBreadcrumbContextExpressMiddleware); // first, before everything else below
41
+ app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
40
42
  app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
41
43
  app.use(forgeOpsTrackerUserContextMiddleware); // after Passport's own session middleware, if used
42
44
  // ...routes...
@@ -51,8 +53,10 @@ for async handlers; each route would need its own try/catch there instead.
51
53
  ### Fastify
52
54
 
53
55
  ```js
56
+ import { registerForgeOpsTrackerBreadcrumbContext } from "@forge-ops/tracker/integrations/breadcrumb-context";
54
57
  import { registerForgeOpsTracker } from "@forge-ops/tracker/integrations/fastify";
55
58
 
59
+ registerForgeOpsTrackerBreadcrumbContext(app); // first, before registerForgeOpsTracker
56
60
  registerForgeOpsTracker(app); // called directly, not via app.register()
57
61
  ```
58
62
 
@@ -143,6 +147,57 @@ just through a synchronous call chain. Composes with the manual API above rather
143
147
  it: call `runWithUser` yourself for a route (or a custom auth setup this can't detect) that needs
144
148
  to override what was auto-detected.
145
149
 
150
+ ## Breadcrumbs
151
+
152
+ A trail of what happened right before an error. With `forgeOpsTrackerBreadcrumbContextExpressMiddleware`/
153
+ `registerForgeOpsTrackerBreadcrumbContext` installed (see the Express/Fastify snippets above), every
154
+ request gets its own trail, and the Express performance middleware records a `"controller"`
155
+ breadcrumb into it automatically, no further setup needed. Shows up alongside the error on an
156
+ issue's own detail page.
157
+
158
+ ```js
159
+ forgeOpsTracker.init({
160
+ dsn: "...",
161
+ trackBreadcrumbs: false, // opt out of the automatic sources entirely
162
+ maxBreadcrumbs: 30, // oldest entry dropped once this many have accumulated in one request
163
+ });
164
+ ```
165
+
166
+ Add your own by hand, regardless of whether the automatic sources are on:
167
+
168
+ ```js
169
+ forgeOpsTracker.addBreadcrumb("charged card", { category: "billing", data: { orderId: order.id } });
170
+ ```
171
+
172
+ `category` defaults to `"custom"`, `level` to `"info"` (`"debug"`/`"info"`/`"warning"`/`"error"` are
173
+ the four levels the automatic sources themselves use too), and `data` to `{}`. Works outside a
174
+ request entirely too (a plain script, a worker with no breadcrumb-context middleware/hook
175
+ installed): the buffer it adds to is created lazily wherever it's first called, the same "works
176
+ standalone, no specific setup required" shape the manual API in every other SDK in this repo
177
+ already has.
178
+
179
+ Each request gets its own fresh, bounded trail (a ring buffer capped at `maxBreadcrumbs`, oldest
180
+ entry dropped once full), scoped with a second `AsyncLocalStorage` instance, the same mechanism
181
+ `runWithUser()`/the user-context middleware already use for the affected user: Node is
182
+ single-threaded, so a plain module-level variable would leak one request's trail into another's
183
+ interleaved on the same event loop, exactly the bug `AsyncLocalStorage` avoids here. Confirmed
184
+ directly, with a real Express app and a real Fastify app, each running two genuinely concurrent,
185
+ interleaved requests, that one request's trail never bleeds into the other's, and that a
186
+ breadcrumb recorded deep inside an awaited route handler (or the performance middleware's own
187
+ `res.on("finish")` listener, which fires later still) is still visible to `captureException()` at
188
+ the end of that same request. Unlike the affected user above, a breadcrumb's `message`/`data`
189
+ **is** scrubbed for likely PII: console-style/query/request trail entries are exactly the kind of
190
+ free text (a bind parameter showing up in a message, a URL with a token in it) the scrubber exists
191
+ to catch, not a deliberately-structured field the way `user` is.
192
+
193
+ There's currently no query-level automatic breadcrumb source: this client has no ORM/DB driver
194
+ integration to hang one off of yet, the same reason there's no query-level performance
195
+ instrumentation either. Fastify also gets no automatic `"controller"` breadcrumb today, since
196
+ there's no Fastify performance/timing integration in this client for one to ride alongside (see
197
+ "Performance monitoring" below); `registerForgeOpsTrackerBreadcrumbContext` still gives Fastify
198
+ requests their own trail, so `addBreadcrumb()` calls from inside a Fastify route handler work
199
+ correctly and show up on that request's error.
200
+
146
201
  ## Delivery: an async loop, not a thread
147
202
 
148
203
  `DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
@@ -178,6 +233,8 @@ and redacted before the payload ever leaves this process. ForgeOps itself scrubs
178
233
  regardless, so this is a second, earlier layer, not the only one. The user attached via
179
234
  `captureException`'s third argument or `runWithUser` above is a deliberate exception: it's never
180
235
  scrubbed, since redacting it would defeat the whole point of identifying users in the first place.
236
+ Breadcrumbs (see "Breadcrumbs" above) are *not* exempt, unlike the user: they're scrubbed the same
237
+ as the message/backtrace/context.
181
238
 
182
239
  To disable it:
183
240
 
@@ -240,7 +297,15 @@ runs, diffed once the response finishes) so a dashboard widget on ForgeOps can s
240
297
  of your app are actually slow, not just which ones raise. Bucketed by transaction
241
298
  (`"GET /users/:id"`, the matched route pattern rather than the literal URL, so a distinct user id
242
299
  doesn't explode into its own separate transaction) and flushed as a small periodic aggregate per
243
- transaction on the same kind of `setInterval` timer session tracking above uses.
300
+ transaction on the same kind of `setInterval` timer session tracking above uses. The same
301
+ middleware also records a `"controller"` breadcrumb (see "Breadcrumbs" above), gated on
302
+ `trackBreadcrumbs` independently of `trackPerformance`: turning either one off doesn't affect the
303
+ other.
304
+
305
+ Each aggregate also carries a small latency histogram (a count per fixed latency bucket: 50, 100,
306
+ 250, 500, 1000, 2500, 5000 and 10000ms, plus an overflow bucket), so ForgeOps can show an
307
+ approximate p50/p95/p99 per transaction, not just an average. Percentiles are accurate to the width
308
+ of whichever bucket a duration falls into; the SDK never stores the individual durations.
244
309
 
245
310
  ```js
246
311
  forgeOpsTracker.init({
@@ -259,6 +324,100 @@ Fastify isn't supported yet: unlike session tracking, there's no existing reques
259
324
  to build this on for Fastify today, so it's a separate piece of work rather than something this
260
325
  version already covers.
261
326
 
327
+ ## Custom metrics and infrastructure monitoring
328
+
329
+ Two explicit calls (nothing is automatic, so there is no `track*` flag): a business event you name
330
+ yourself, and a reading from one of your own hosts.
331
+
332
+ ```js
333
+ forgeOpsTracker.captureMetric("signup"); // value defaults to 1: a bare counter
334
+ forgeOpsTracker.captureMetric("payment", 49); // a real magnitude; it may be negative (a refund)
335
+
336
+ forgeOpsTracker.captureInfrastructureMetric("cpu", 0.42); // hostname defaults to serverName
337
+ forgeOpsTracker.captureInfrastructureMetric("disk", 0.81, { hostname: "db-1" });
338
+ await forgeOpsTracker.flushMetrics(); // optional: send right now
339
+ ```
340
+
341
+ Each capture is buffered and flushed as one batch every `metricFlushIntervalMs` /
342
+ `infrastructureMetricFlushIntervalMs` (60000 by default) on an unref'd `setInterval` timer, and once
343
+ more when the event loop drains (`beforeExit`, which does not change how the process exits the way a
344
+ `SIGTERM` listener would), so a short-lived cron script that captures a few readings and ends needs
345
+ nothing more. Call `await flushMetrics()` if it exits another way (`process.exit()`). Every entry is
346
+ stored as it was captured (a signup is a row, not a running total), so a count or sum you compute later
347
+ is exact. Both are a no-op when the client isn't enabled for the environment.
348
+
349
+ A failed delivery keeps every entry for the next flush, and an entry captured while a delivery is in
350
+ flight is kept too (the Ruby gem's own buffer loses it). The buffer holds at most 1000 entries per
351
+ kind and drops further ones until a flush succeeds, since a plan without the feature rejects every
352
+ flush and would otherwise grow it for as long as the process lives. A NaN or infinite value is
353
+ dropped at capture: `JSON.stringify` turns it into `null` and the server would reject the whole
354
+ batch behind it. Requires a ForgeOps plan that includes custom metrics / infrastructure monitoring.
355
+
356
+ ## Distributed tracing
357
+
358
+ For one slow request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
359
+ `registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
360
+ call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under
361
+ it, so ForgeOps can render a waterfall for that one request.
362
+
363
+ ```js
364
+ import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
365
+
366
+ app.use(forgeOpsTrackerTracingExpressMiddleware);
367
+ ```
368
+
369
+ ```js
370
+ import { registerForgeOpsTrackerTracing } from "@forge-ops/tracker/integrations/tracing";
371
+
372
+ registerForgeOpsTrackerTracing(fastify);
373
+ ```
374
+
375
+ This is the whole point of the feature, so it's worth being explicit about: a request's own trace
376
+ is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided
377
+ entirely client-side before a single byte goes over the wire. A normal, fast request costs
378
+ nothing extra.
379
+
380
+ ```js
381
+ forgeOpsTracker.init({
382
+ dsn: "...",
383
+ trackTracing: false, // opt out entirely
384
+ traceCaptureThresholdMs: 500, // default 1000
385
+ });
386
+ ```
387
+
388
+ Outbound HTTP calls made via Node's built-in `http`/`https` modules (and so anything built on top
389
+ of them, like `axios` or `node-fetch`) nest in automatically, no extra setup: `init()` always
390
+ installs this hook, the same way `gems/forge_ops_tracker`'s own `Net::HTTP.prepend` always applies
391
+ regardless of config, checking `trackTracing` fresh on every actual call rather than at install
392
+ time. Every span name (`"GET api.stripe.com"`) is low-cardinality by design, the same as every
393
+ transaction name elsewhere in this SDK: never the raw URL path or query string, since either can
394
+ carry a customer's own id or a secret.
395
+
396
+ There's no way to auto-detect "this is a logically distinct service layer" the way an outbound
397
+ HTTP call already has a real hook to extend, so wrap your own service-layer code by hand to have
398
+ it show up as its own span:
399
+
400
+ ```js
401
+ await forgeOpsTracker.span("PaymentService.charge", () => chargeCard(order));
402
+ ```
403
+
404
+ Works with both synchronous and `async` callbacks (the returned promise, if any, is awaited
405
+ before the span is recorded), and nests correctly even when two spans are awaited concurrently in
406
+ the same request (`Promise.all`), since span nesting is tracked per async execution branch via
407
+ `AsyncLocalStorage`, not a single shared stack. `kind` accepts `"controller"`, `"service"` (the
408
+ default), `"database"`, `"redis"`, `"http"`, `"job"`, or `"other"`, and takes an options object:
409
+ `forgeOpsTracker.span("PaymentService.charge", fn, { kind: "service", data: {} })`. A no-op
410
+ outside of a request currently being traced (a plain script, a request that already finished) or
411
+ with `trackTracing` off: it just runs the callback and records nothing, never throwing.
412
+
413
+ Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured
414
+ trace is simply rejected server-side and dropped, exactly like any other delivery failure.
415
+
416
+ **Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
417
+ of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
418
+ here either); a database call or Redis call inside a traced request just won't show up as its own
419
+ span for now.
420
+
262
421
  ## Running the tests
263
422
 
264
423
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.5.0",
4
- "description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.",
3
+ "version": "0.9.1",
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",
7
7
  "exports": {
@@ -9,7 +9,9 @@
9
9
  "./integrations/express": "./src/integrations/express.js",
10
10
  "./integrations/fastify": "./src/integrations/fastify.js",
11
11
  "./integrations/session-tracking": "./src/integrations/sessionTracking.js",
12
- "./integrations/performance": "./src/integrations/performance.js"
12
+ "./integrations/performance": "./src/integrations/performance.js",
13
+ "./integrations/breadcrumb-context": "./src/integrations/breadcrumbContext.js",
14
+ "./integrations/tracing": "./src/integrations/tracing.js"
13
15
  },
14
16
  "engines": {
15
17
  "node": ">=18"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A bounded, in-order trail of whatever happened recently in this request: the controller/route
3
+ * lifecycle, plus anything added by hand via addBreadcrumb() in index.js, recorded automatically
4
+ * once a breadcrumb-context middleware/hook (see integrations/breadcrumbContext.js) gives the
5
+ * request its own buffer to accumulate into. Ported from
6
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/breadcrumb_buffer.rb: the same ring buffer contract
7
+ * (capped at Configuration#maxBreadcrumbs, oldest entry dropped once full), but scoped per request
8
+ * via AsyncLocalStorage rather than Ruby's Thread.current or Python's contextvars.ContextVar; see
9
+ * index.js's own breadcrumbStorage comment for why that's the correct mechanism here.
10
+ */
11
+ export class BreadcrumbBuffer {
12
+ #configuration;
13
+ #entries = [];
14
+
15
+ constructor(configuration) {
16
+ this.#configuration = configuration;
17
+ }
18
+
19
+ /**
20
+ * @param {string} message
21
+ * @param {{ category?: string, level?: string, data?: Record<string, unknown> }} [options]
22
+ */
23
+ add(message, { category = "custom", level = "info", data = {} } = {}) {
24
+ // Read fresh on every add, not captured once at construction: the same reasoning
25
+ // DeliveryQueue re-reads Configuration#queueSize on every push(), so a config change from
26
+ // init() takes effect on whatever's added next, not just on a buffer created earlier.
27
+ const maxSize = Math.max(this.#configuration.maxBreadcrumbs, 0);
28
+ if (maxSize === 0) {
29
+ return;
30
+ }
31
+
32
+ this.#entries.push({
33
+ category: String(category),
34
+ message: String(message),
35
+ level: String(level),
36
+ timestamp: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
37
+ data: data ?? {},
38
+ });
39
+ while (this.#entries.length > maxSize) {
40
+ this.#entries.shift();
41
+ }
42
+ }
43
+
44
+ /** @returns {Array<Record<string, unknown>>} a copy, never the internal array itself */
45
+ all() {
46
+ return [...this.#entries];
47
+ }
48
+ }
package/src/client.js CHANGED
@@ -39,6 +39,35 @@ export class Client {
39
39
  return this.#post(this.#configuration.performanceSamplesUri(), { samples });
40
40
  }
41
41
 
42
+ /**
43
+ * One whole captured trace (a trace_id plus every span belonging to it) delivered as one POST,
44
+ * unlike deliverPerformanceSamples' own batched-over-a-time-window shape: a trace is already a
45
+ * complete, immediately relevant unit the moment its own request finishes, matching
46
+ * Api::V1::SpansController's own expected body and gems/forge_ops_tracker's own
47
+ * Client#deliver_spans.
48
+ * @param {{ trace_id: string, spans: Array<Record<string, unknown>> }} payload
49
+ */
50
+ async deliverSpans(payload) {
51
+ return this.#post(this.#configuration.spansUri(), payload);
52
+ }
53
+
54
+ /**
55
+ * A batch of individual capture_metric entries, posted as { metrics }, matching
56
+ * Api::V1::CustomMetricsController's own expected body.
57
+ * @param {Record<string, unknown>[]} metrics
58
+ */
59
+ async deliverMetrics(metrics) {
60
+ return this.#post(this.#configuration.customMetricsUri(), { metrics });
61
+ }
62
+
63
+ /**
64
+ * Same again for infrastructure readings; see Api::V1::InfrastructureMetricsController.
65
+ * @param {Record<string, unknown>[]} metrics
66
+ */
67
+ async deliverInfrastructureMetrics(metrics) {
68
+ return this.#post(this.#configuration.infrastructureMetricsUri(), { metrics });
69
+ }
70
+
42
71
  /**
43
72
  * @param {string | null} uri
44
73
  * @param {Record<string, unknown>} payload
@@ -65,6 +65,42 @@ export class Configuration {
65
65
  * one small batch on this interval" reasoning as sessionFlushIntervalMs above. */
66
66
  performanceFlushIntervalMs = 60000;
67
67
 
68
+ /** Milliseconds between flushes of the buffered captureMetric()/captureInfrastructureMetric()
69
+ * entries (see metricBuffer.js). No trackMetrics flag the way trackPerformance has one: these are
70
+ * explicit calls the host app's own code makes, not automatic instrumentation, so there is nothing
71
+ * to turn off that simply not calling them doesn't already do. */
72
+ metricFlushIntervalMs = 60000;
73
+ infrastructureMetricFlushIntervalMs = 60000;
74
+
75
+ /**
76
+ * Whether the Express/Fastify integrations give every request its own breadcrumb trail, and
77
+ * whatever automatic sources this client already times (currently just the Express
78
+ * controller/route lifecycle; see integrations/performance.js) also record a breadcrumb
79
+ * alongside the timing they already do. On by default, the same posture trackSessions/
80
+ * trackPerformance above already have. addBreadcrumb() itself is never gated by this: only the
81
+ * automatic sources are, matching gems/forge_ops_tracker's own track_breadcrumbs.
82
+ */
83
+ trackBreadcrumbs = true;
84
+ /** Oldest entry dropped once this many have accumulated in a single request: the same "bounded
85
+ * ring buffer, not an unbounded log" reasoning sdks/typescript's own Configuration#maxBreadcrumbs
86
+ * already documents. */
87
+ maxBreadcrumbs = 30;
88
+
89
+ /**
90
+ * Whether the Express/Fastify integrations capture one request's own full nested call tree (its
91
+ * root span plus every service/database/http/redis span recorded underneath it) as a
92
+ * distributed trace, once that request's own total duration crosses traceCaptureThresholdMs
93
+ * below. On by default, the same "on unless you turn it off" posture every other automatic
94
+ * instrumentation flag above already has; a fast request still costs nothing regardless, since
95
+ * the send-or-don't decision happens after the fact, per request, not by disabling this flag.
96
+ */
97
+ trackTracing = true;
98
+ /** A request's own root span duration has to reach at least this many milliseconds before its
99
+ * whole trace is sent at all; this is the entire point of the feature, not a sampling knob: a
100
+ * normal, fast request never costs a single byte over the wire. Matches
101
+ * gems/forge_ops_tracker's own Configuration#trace_capture_threshold_ms default. */
102
+ traceCaptureThresholdMs = 1000;
103
+
68
104
  /** @returns {string | null} */
69
105
  apiKey() {
70
106
  const parsed = this.#parsedDsn();
@@ -115,6 +151,43 @@ export class Configuration {
115
151
  return uri.replace(/\/events$/, "/performance_samples");
116
152
  }
117
153
 
154
+ /**
155
+ * Same derivation again, swapping the trailing /events for /custom_metrics.
156
+ * @returns {string | null}
157
+ */
158
+ customMetricsUri() {
159
+ const uri = this.ingestionUri();
160
+ if (!uri) {
161
+ return null;
162
+ }
163
+ return uri.replace(/\/events$/, "/custom_metrics");
164
+ }
165
+
166
+ /**
167
+ * Same derivation again, swapping the trailing /events for /infrastructure_metrics.
168
+ * @returns {string | null}
169
+ */
170
+ infrastructureMetricsUri() {
171
+ const uri = this.ingestionUri();
172
+ if (!uri) {
173
+ return null;
174
+ }
175
+ return uri.replace(/\/events$/, "/infrastructure_metrics");
176
+ }
177
+
178
+ /**
179
+ * Same derivation again, swapping the trailing /events for /spans: one captured trace, one POST,
180
+ * matching gems/forge_ops_tracker's own Configuration#spans_uri.
181
+ * @returns {string | null}
182
+ */
183
+ spansUri() {
184
+ const uri = this.ingestionUri();
185
+ if (!uri) {
186
+ return null;
187
+ }
188
+ return uri.replace(/\/events$/, "/spans");
189
+ }
190
+
118
191
  /** @returns {boolean} */
119
192
  isEnabled() {
120
193
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -23,19 +23,32 @@
23
23
  export class DeliveryQueue {
24
24
  #configuration;
25
25
  #client;
26
+ #deliverMethod;
27
+ #label;
26
28
  #queue = [];
27
29
  #processing = false;
28
30
 
29
- constructor(configuration, client) {
31
+ /**
32
+ * deliverMethod/label default to Client#deliver's own "event" shape, so every existing call
33
+ * site (Reporter, and every test written against it) keeps working unchanged. Spans reuse this
34
+ * same queue rather than a second, near-identical class (the shape gems/forge_ops_tracker's own
35
+ * SpanQueue takes instead, since Ruby's DeliveryQueue has no such parameter to extend): only the
36
+ * client method actually called, and the word used in a dropped/failed log line, ever differ
37
+ * between an error event and a captured trace.
38
+ * @param {{ deliverMethod?: string, label?: string }} [options]
39
+ */
40
+ constructor(configuration, client, { deliverMethod = "deliver", label = "event" } = {}) {
30
41
  this.#configuration = configuration;
31
42
  this.#client = client;
43
+ this.#deliverMethod = deliverMethod;
44
+ this.#label = label;
32
45
  }
33
46
 
34
47
  /** @param {Record<string, unknown>} payload */
35
48
  push(payload) {
36
49
  const maxSize = Math.max(1, this.#configuration.queueSize);
37
50
  if (this.#queue.length >= maxSize) {
38
- this.#configuration.log("[forge-ops-tracker] delivery queue full, dropping event");
51
+ this.#configuration.log(`[forge-ops-tracker] delivery queue full, dropping ${this.#label}`);
39
52
  return false;
40
53
  }
41
54
 
@@ -59,7 +72,7 @@ export class DeliveryQueue {
59
72
  while (this.#queue.length > 0) {
60
73
  const payload = this.#queue.shift();
61
74
  try {
62
- await this.#client.deliver(payload);
75
+ await this.#client[this.#deliverMethod](payload);
63
76
  } catch (e) {
64
77
  // Per-item, not wrapping the whole loop: one bad delivery must
65
78
  // not stop every event queued after it.
@@ -47,8 +47,9 @@ export class EventBuilder {
47
47
  * @param {Error} error
48
48
  * @param {Record<string, unknown>} [context]
49
49
  * @param {Record<string, unknown> | null} [user]
50
+ * @param {Array<Record<string, unknown>>} [breadcrumbs]
50
51
  */
51
- build(error, context = {}, user = null) {
52
+ build(error, context = {}, user = null, breadcrumbs = []) {
52
53
  const payload = {
53
54
  exception_class: error?.name ?? "Error",
54
55
  message: error?.message ?? "",
@@ -64,6 +65,9 @@ export class EventBuilder {
64
65
  if (user && Object.keys(user).length > 0) {
65
66
  payload.user = { ...user };
66
67
  }
68
+ if (breadcrumbs && breadcrumbs.length > 0) {
69
+ payload.breadcrumbs = [...breadcrumbs];
70
+ }
67
71
 
68
72
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
69
73
  }
@@ -74,15 +78,23 @@ export class EventBuilder {
74
78
  // accidentally spill sensitive data into. user specifically is a
75
79
  // deliberate exemption, not an oversight: the scrubber's own email
76
80
  // pattern would otherwise redact the exact thing this field exists to
77
- // carry.
81
+ // carry. breadcrumbs is *not* exempt, unlike user: console-style/query/
82
+ // request trail entries are exactly the kind of free text (a bind
83
+ // parameter showing up in a message, a URL with a token in it) the
84
+ // scrubber exists to catch, matching the server's own
85
+ // api/v1/events_controller.rb treatment of this field.
78
86
  #scrub(payload) {
79
- return {
87
+ const scrubbed = {
80
88
  ...payload,
81
89
  message: scrubString(payload.message),
82
90
  backtrace: payload.backtrace.map((frame) => this.#scrubFrame(frame)),
83
91
  context: scrub(payload.context),
84
92
  tags: scrub(payload.tags),
85
93
  };
94
+ if ("breadcrumbs" in payload) {
95
+ scrubbed.breadcrumbs = scrub(payload.breadcrumbs);
96
+ }
97
+ return scrubbed;
86
98
  }
87
99
 
88
100
  #scrubFrame(frame) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Buckets a single duration into one of a fixed set of latency-range labels, the building block
3
+ * PerformanceFlusher uses to accumulate an approximate distribution (not just count/sum/max)
4
+ * alongside every transaction bucket it already tallies. The server merges these counts across
5
+ * matching samples at read time and walks cumulative counts to approximate a percentile, accurate
6
+ * to the bucket width: this SDK never stores the raw duration list a true percentile would need.
7
+ * Ported from gems/forge_ops_tracker/lib/forge_ops_tracker/histogram_bucketer.rb.
8
+ *
9
+ * BOUNDARIES_MS is duplicated on the server side, in app/services/histogram_percentile.rb. Change
10
+ * one, change the other, or a released SDK version and the server it talks to would silently
11
+ * disagree about what each bucket label means.
12
+ */
13
+ export const BOUNDARIES_MS = Object.freeze([50, 100, 250, 500, 1000, 2500, 5000, 10000]);
14
+
15
+ /**
16
+ * Returns the label (a string) of the smallest boundary durationMs fits under, or "inf" for
17
+ * anything larger than the largest boundary. A string, not a number: this travels as a JSON
18
+ * object key once flushed, and JSON object keys are always strings.
19
+ *
20
+ * @param {number} durationMs
21
+ * @returns {string}
22
+ */
23
+ export function bucketFor(durationMs) {
24
+ const boundary = BOUNDARIES_MS.find((b) => durationMs <= b);
25
+ return boundary === undefined ? "inf" : String(boundary);
26
+ }
@@ -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
+ }