@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.
- package/README.md +201 -8
- 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 +24 -5
- package/src/histogramBucketer.js +26 -0
- package/src/httpTracing.js +104 -0
- package/src/index.js +409 -3
- package/src/integrations/breadcrumbContext.js +70 -0
- package/src/integrations/express.js +50 -1
- 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 +4 -2
- package/src/spanBuffer.js +89 -0
package/README.md
CHANGED
|
@@ -7,11 +7,8 @@ events to ForgeOps over HTTP without blocking the request or process that raised
|
|
|
7
7
|
|
|
8
8
|
## Installation
|
|
9
9
|
|
|
10
|
-
Not yet published to npm: install directly from this path (or a local checkout, once split into
|
|
11
|
-
its own repo):
|
|
12
|
-
|
|
13
10
|
```bash
|
|
14
|
-
npm install /
|
|
11
|
+
npm install @forge-ops/tracker
|
|
15
12
|
```
|
|
16
13
|
|
|
17
14
|
## Configuration
|
|
@@ -35,12 +32,15 @@ overridden by passing it in the options object.
|
|
|
35
32
|
### Express
|
|
36
33
|
|
|
37
34
|
```js
|
|
35
|
+
import { forgeOpsTrackerBreadcrumbContextExpressMiddleware } from "@forge-ops/tracker/integrations/breadcrumb-context";
|
|
38
36
|
import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
|
|
39
37
|
import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
|
|
40
|
-
import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
|
|
38
|
+
import { forgeOpsTrackerUserContextMiddleware, forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
|
|
41
39
|
|
|
42
|
-
app.use(
|
|
40
|
+
app.use(forgeOpsTrackerBreadcrumbContextExpressMiddleware); // first, before everything else below
|
|
41
|
+
app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
|
|
43
42
|
app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
|
|
43
|
+
app.use(forgeOpsTrackerUserContextMiddleware); // after Passport's own session middleware, if used
|
|
44
44
|
// ...routes...
|
|
45
45
|
app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
|
|
46
46
|
```
|
|
@@ -53,8 +53,10 @@ for async handlers; each route would need its own try/catch there instead.
|
|
|
53
53
|
### Fastify
|
|
54
54
|
|
|
55
55
|
```js
|
|
56
|
+
import { registerForgeOpsTrackerBreadcrumbContext } from "@forge-ops/tracker/integrations/breadcrumb-context";
|
|
56
57
|
import { registerForgeOpsTracker } from "@forge-ops/tracker/integrations/fastify";
|
|
57
58
|
|
|
59
|
+
registerForgeOpsTrackerBreadcrumbContext(app); // first, before registerForgeOpsTracker
|
|
58
60
|
registerForgeOpsTracker(app); // called directly, not via app.register()
|
|
59
61
|
```
|
|
60
62
|
|
|
@@ -111,6 +113,91 @@ themselves, long before it would ever reach here.
|
|
|
111
113
|
Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
|
|
112
114
|
dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
|
|
113
115
|
|
|
116
|
+
## Identifying users
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
captureException(error, {}, { id: user.id, email: user.email });
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Or `runWithUser(user, callback)` to attach it to every `captureException()` call made anywhere in
|
|
123
|
+
`callback`'s own async chain, rather than passing it by hand every time, e.g. from your own
|
|
124
|
+
middleware:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
app.use((req, res, next) => runWithUser({ id: req.user?.id, email: req.user?.email }, next));
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
There's no imperative `setUser()` the way some other languages in this repo have: Node is
|
|
131
|
+
single-threaded, so a plain module-level variable would leak across concurrent requests
|
|
132
|
+
interleaved on the same event loop, exactly the bug the other languages' own thread-local choice
|
|
133
|
+
avoids for their own concurrency model. `AsyncLocalStorage` (Node's own built-in mechanism for
|
|
134
|
+
this) only propagates a value through an async call chain that was explicitly wrapped, so
|
|
135
|
+
`runWithUser`'s wrapping shape is the correct, idiomatic choice here, not a limitation being
|
|
136
|
+
worked around. `id`/`email`/`username` are all independently optional. Shows up on an issue's own
|
|
137
|
+
detail page, and as its own affected-users count alongside the regular event count.
|
|
138
|
+
|
|
139
|
+
**Express apps get this automatically**: add `forgeOpsTrackerUserContextMiddleware` (see the
|
|
140
|
+
Express snippet above), after whatever middleware actually sets `req.user` (Passport's own session
|
|
141
|
+
middleware, the closest thing Express has to a single dominant auth library, the same role Warden
|
|
142
|
+
plays for Rails). A no-op when `req.user` is never set, whether that's because nobody's signed in
|
|
143
|
+
or Passport isn't installed. Confirmed directly, with a real Express app and a real async route
|
|
144
|
+
handler, that the `AsyncLocalStorage` context this sets survives all the way through to
|
|
145
|
+
`forgeOpsTrackerExpressMiddleware` later in the same request, including across an `await`, not
|
|
146
|
+
just through a synchronous call chain. Composes with the manual API above rather than replacing
|
|
147
|
+
it: call `runWithUser` yourself for a route (or a custom auth setup this can't detect) that needs
|
|
148
|
+
to override what was auto-detected.
|
|
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
|
+
|
|
114
201
|
## Delivery: an async loop, not a thread
|
|
115
202
|
|
|
116
203
|
`DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
|
|
@@ -143,7 +230,11 @@ By default, the message, backtrace, and any context/tags you attach are scanned
|
|
|
143
230
|
for likely personal data: email addresses, formatted SSNs/credit cards, known API key/token
|
|
144
231
|
formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar),
|
|
145
232
|
and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
|
|
146
|
-
regardless, so this is a second, earlier layer, not the only one.
|
|
233
|
+
regardless, so this is a second, earlier layer, not the only one. The user attached via
|
|
234
|
+
`captureException`'s third argument or `runWithUser` above is a deliberate exception: it's never
|
|
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.
|
|
147
238
|
|
|
148
239
|
To disable it:
|
|
149
240
|
|
|
@@ -206,7 +297,15 @@ runs, diffed once the response finishes) so a dashboard widget on ForgeOps can s
|
|
|
206
297
|
of your app are actually slow, not just which ones raise. Bucketed by transaction
|
|
207
298
|
(`"GET /users/:id"`, the matched route pattern rather than the literal URL, so a distinct user id
|
|
208
299
|
doesn't explode into its own separate transaction) and flushed as a small periodic aggregate per
|
|
209
|
-
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.
|
|
210
309
|
|
|
211
310
|
```js
|
|
212
311
|
forgeOpsTracker.init({
|
|
@@ -225,6 +324,100 @@ Fastify isn't supported yet: unlike session tracking, there's no existing reques
|
|
|
225
324
|
to build this on for Fastify today, so it's a separate piece of work rather than something this
|
|
226
325
|
version already covers.
|
|
227
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
|
+
|
|
228
421
|
## Running the tests
|
|
229
422
|
|
|
230
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
|
@@ -46,8 +46,10 @@ export class EventBuilder {
|
|
|
46
46
|
/**
|
|
47
47
|
* @param {Error} error
|
|
48
48
|
* @param {Record<string, unknown>} [context]
|
|
49
|
+
* @param {Record<string, unknown> | null} [user]
|
|
50
|
+
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
49
51
|
*/
|
|
50
|
-
build(error, context = {}) {
|
|
52
|
+
build(error, context = {}, user = null, breadcrumbs = []) {
|
|
51
53
|
const payload = {
|
|
52
54
|
exception_class: error?.name ?? "Error",
|
|
53
55
|
message: error?.message ?? "",
|
|
@@ -60,22 +62,39 @@ export class EventBuilder {
|
|
|
60
62
|
tags: {},
|
|
61
63
|
sdk_name: SDK_NAME,
|
|
62
64
|
};
|
|
65
|
+
if (user && Object.keys(user).length > 0) {
|
|
66
|
+
payload.user = { ...user };
|
|
67
|
+
}
|
|
68
|
+
if (breadcrumbs && breadcrumbs.length > 0) {
|
|
69
|
+
payload.breadcrumbs = [...breadcrumbs];
|
|
70
|
+
}
|
|
63
71
|
|
|
64
72
|
return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
// exception_class/occurred_at/environment/release/server_name are
|
|
68
|
-
// alone: structured fields this client or the host app sets
|
|
75
|
+
// exception_class/occurred_at/environment/release/server_name/user are
|
|
76
|
+
// left alone: structured fields this client or the host app sets
|
|
69
77
|
// deliberately, not free text an exception or its context could
|
|
70
|
-
// accidentally spill sensitive data into.
|
|
78
|
+
// accidentally spill sensitive data into. user specifically is a
|
|
79
|
+
// deliberate exemption, not an oversight: the scrubber's own email
|
|
80
|
+
// pattern would otherwise redact the exact thing this field exists to
|
|
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.
|
|
71
86
|
#scrub(payload) {
|
|
72
|
-
|
|
87
|
+
const scrubbed = {
|
|
73
88
|
...payload,
|
|
74
89
|
message: scrubString(payload.message),
|
|
75
90
|
backtrace: payload.backtrace.map((frame) => this.#scrubFrame(frame)),
|
|
76
91
|
context: scrub(payload.context),
|
|
77
92
|
tags: scrub(payload.tags),
|
|
78
93
|
};
|
|
94
|
+
if ("breadcrumbs" in payload) {
|
|
95
|
+
scrubbed.breadcrumbs = scrub(payload.breadcrumbs);
|
|
96
|
+
}
|
|
97
|
+
return scrubbed;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
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
|
+
}
|