@forge-ops/tracker 0.5.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.
- package/README.md +161 -2
- package/package.json +4 -2
- package/src/breadcrumbBuffer.js +48 -0
- package/src/client.js +29 -0
- package/src/configuration.js +73 -0
- package/src/deliveryQueue.js +16 -3
- package/src/eventBuilder.js +15 -3
- package/src/histogramBucketer.js +26 -0
- package/src/httpTracing.js +104 -0
- package/src/index.js +371 -1
- package/src/integrations/breadcrumbContext.js +70 -0
- package/src/integrations/performance.js +29 -3
- package/src/integrations/tracing.js +80 -0
- package/src/metricBuffer.js +117 -0
- package/src/performanceFlusher.js +43 -5
- package/src/reporter.js +3 -2
- package/src/spanBuffer.js +89 -0
package/README.md
CHANGED
|
@@ -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(
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -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
|
package/src/configuration.js
CHANGED
|
@@ -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);
|
package/src/deliveryQueue.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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.
|
package/src/eventBuilder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|