@forge-ops/tracker 0.10.0 → 0.11.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 +79 -3
- package/package.json +1 -1
- package/src/configuration.js +65 -0
- package/src/eventBuilder.js +20 -3
- package/src/httpTracing.js +53 -10
- package/src/index.js +91 -6
- package/src/integrations/express.js +16 -5
- package/src/integrations/fastify.js +18 -5
- package/src/integrations/tracing.js +73 -16
- package/src/reporter.js +3 -2
- package/src/requestState.js +117 -0
- package/src/spanBuffer.js +32 -9
- package/src/traceParent.js +88 -0
package/README.md
CHANGED
|
@@ -113,6 +113,28 @@ themselves, long before it would ever reach here.
|
|
|
113
113
|
Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
|
|
114
114
|
dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
|
|
115
115
|
|
|
116
|
+
## Where an error happened
|
|
117
|
+
|
|
118
|
+
An error reported during a request carries three extra fields:
|
|
119
|
+
|
|
120
|
+
- `transaction_name`: the method and the route pattern, `"GET /orders/:id"`, the same name
|
|
121
|
+
performance monitoring and traces use.
|
|
122
|
+
- `endpoint`: the method and the route as declared, `"GET /orders/:id"`. Never the literal path, so
|
|
123
|
+
an id or a token in the URL never ends up here. On Express, it's the route as declared on the
|
|
124
|
+
router that matched it: for a router mounted with `app.use("/api", router)`, that's the part
|
|
125
|
+
declared on the router, without `/api`, since the mount path Express exposes is the literal
|
|
126
|
+
matched one.
|
|
127
|
+
- `trace_id`: the request's W3C trace id, which ForgeOps uses to link this error to errors that
|
|
128
|
+
other services reported for the same trace (see "Following a request across services" below).
|
|
129
|
+
|
|
130
|
+
Register `forgeOpsTrackerTracingExpressMiddleware` before your routes, or call
|
|
131
|
+
`registerForgeOpsTrackerTracing(app)` on Fastify (see "Distributed tracing" below), for these to be
|
|
132
|
+
set on an error you capture yourself inside a handler; Fastify knows the route before any handler
|
|
133
|
+
runs, Express as soon as the matched handler starts. `forgeOpsTrackerExpressMiddleware` and
|
|
134
|
+
`registerForgeOpsTracker` add them to an unhandled error on their own. They're left out entirely
|
|
135
|
+
outside a request (a script, a worker), and they're never PII-scrubbed, like `environment` and
|
|
136
|
+
`release`: they're structured fields, not free text.
|
|
137
|
+
|
|
116
138
|
## Identifying users
|
|
117
139
|
|
|
118
140
|
```js
|
|
@@ -355,7 +377,7 @@ batch behind it. Requires a ForgeOps plan that includes custom metrics / infrast
|
|
|
355
377
|
|
|
356
378
|
## Distributed tracing
|
|
357
379
|
|
|
358
|
-
For one slow request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
380
|
+
For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
359
381
|
`registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
|
|
360
382
|
call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under
|
|
361
383
|
it, so ForgeOps can render a waterfall for that one request.
|
|
@@ -363,7 +385,7 @@ it, so ForgeOps can render a waterfall for that one request.
|
|
|
363
385
|
```js
|
|
364
386
|
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
365
387
|
|
|
366
|
-
app.use(forgeOpsTrackerTracingExpressMiddleware);
|
|
388
|
+
app.use(forgeOpsTrackerTracingExpressMiddleware); // before your routes
|
|
367
389
|
```
|
|
368
390
|
|
|
369
391
|
```js
|
|
@@ -375,7 +397,9 @@ registerForgeOpsTrackerTracing(fastify);
|
|
|
375
397
|
This is the whole point of the feature, so it's worth being explicit about: a request's own trace
|
|
376
398
|
is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided
|
|
377
399
|
entirely client-side before a single byte goes over the wire. A normal, fast request costs
|
|
378
|
-
nothing extra.
|
|
400
|
+
nothing extra. A request that errored (an unhandled error, or any `captureException()` during it)
|
|
401
|
+
is the one exception: its trace is always sent, however fast it was, since the spans leading up to
|
|
402
|
+
an error are exactly what you want next to it.
|
|
379
403
|
|
|
380
404
|
```js
|
|
381
405
|
forgeOpsTracker.init({
|
|
@@ -413,6 +437,58 @@ with `trackTracing` off: it just runs the callback and records nothing, never th
|
|
|
413
437
|
Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured
|
|
414
438
|
trace is simply rejected server-side and dropped, exactly like any other delivery failure.
|
|
415
439
|
|
|
440
|
+
### Following a request across services
|
|
441
|
+
|
|
442
|
+
Traces use the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard, so a request can
|
|
443
|
+
be followed from one service into the next, whichever language or tracing library the other
|
|
444
|
+
service uses:
|
|
445
|
+
|
|
446
|
+
- **Incoming**: a request that arrives with a valid `traceparent` header continues that trace (same
|
|
447
|
+
trace id), and its root span records the caller's span as its parent. A missing or malformed
|
|
448
|
+
header just starts a new trace.
|
|
449
|
+
- **Outgoing**: every outbound call made through Node's `http`/`https` modules during a request
|
|
450
|
+
gets a `traceparent` header whose parent id is that call's own span, so the next service's spans
|
|
451
|
+
nest under it. A `traceparent` you set yourself is never replaced, and this client's own
|
|
452
|
+
deliveries to ForgeOps never get one. The global `fetch()` doesn't go through `http`/`https`, so
|
|
453
|
+
it isn't instrumented and gets no header.
|
|
454
|
+
|
|
455
|
+
A trace id exists for every request even with `trackTracing` off, and the header is still sent,
|
|
456
|
+
since the trace id is also what links an error here to an error in the service you called. Seeing
|
|
457
|
+
the two errors connected in ForgeOps needs both services' projects linked there.
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
import http from "node:http";
|
|
461
|
+
import express from "express";
|
|
462
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
463
|
+
import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
|
|
464
|
+
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
465
|
+
|
|
466
|
+
forgeOpsTracker.init({
|
|
467
|
+
dsn: "https://<api_key>@getforgeops.net/api/v1/events",
|
|
468
|
+
propagateTraces: true, // default true; false never sends traceparent
|
|
469
|
+
// Default null: every host. A string matches that host and its subdomains on a dot boundary
|
|
470
|
+
// ("internal.example" matches "orders.internal.example", not "notinternal.example"); a RegExp is
|
|
471
|
+
// tested against the host, without the port.
|
|
472
|
+
tracePropagationTargets: ["internal.example", /^10\.0\./],
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const app = express();
|
|
476
|
+
app.use(forgeOpsTrackerTracingExpressMiddleware);
|
|
477
|
+
|
|
478
|
+
app.post("/checkout", (req, res, next) => {
|
|
479
|
+
// Carries traceparent: 00-<this request's trace id>-<this call's span id>-01
|
|
480
|
+
const call = http.request("http://orders.internal.example/orders", { method: "POST" }, (response) => {
|
|
481
|
+
response.resume();
|
|
482
|
+
res.status(response.statusCode === 201 ? 201 : 502).end();
|
|
483
|
+
});
|
|
484
|
+
call.on("error", next);
|
|
485
|
+
call.end(JSON.stringify({ sku: "A1" }));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
app.use(forgeOpsTrackerExpressMiddleware);
|
|
489
|
+
app.listen(3000);
|
|
490
|
+
```
|
|
491
|
+
|
|
416
492
|
**Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
|
|
417
493
|
of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
|
|
418
494
|
here either); a database call or Redis call inside a traced request just won't show up as its own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
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",
|
package/src/configuration.js
CHANGED
|
@@ -116,6 +116,26 @@ export class Configuration {
|
|
|
116
116
|
* gems/forge_ops_tracker's own Configuration#trace_capture_threshold_ms default. */
|
|
117
117
|
traceCaptureThresholdMs = 1000;
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
|
|
121
|
+
* http/https request made during a request the Express/Fastify tracing integration is handling
|
|
122
|
+
* (see httpTracing.js), so a service that reports to ForgeOps too (or anything else that
|
|
123
|
+
* understands the standard) continues this request's trace instead of starting its own. On by
|
|
124
|
+
* default, the same as gems/forge_ops_tracker's propagate_traces: the header also carries the
|
|
125
|
+
* trace id that links an error here to an error there, which is useful with or without spans.
|
|
126
|
+
* Never overwrites a traceparent the request already has.
|
|
127
|
+
*/
|
|
128
|
+
propagateTraces = true;
|
|
129
|
+
/**
|
|
130
|
+
* Which hosts get that header. null (the default) means every host. Otherwise an array whose
|
|
131
|
+
* entries are either a host string, matching that host and its subdomains on a dot boundary
|
|
132
|
+
* ("example.com" matches "api.example.com", not "badexample.com"; a leading dot is ignored), or a
|
|
133
|
+
* RegExp tested against the host (no port). Narrow it for a third-party API that rejects headers
|
|
134
|
+
* it doesn't know, or that shouldn't learn this app's trace ids at all.
|
|
135
|
+
* @type {Array<string | RegExp> | null}
|
|
136
|
+
*/
|
|
137
|
+
tracePropagationTargets = null;
|
|
138
|
+
|
|
119
139
|
/** @returns {string | null} */
|
|
120
140
|
apiKey() {
|
|
121
141
|
const parsed = this.#parsedDsn();
|
|
@@ -203,6 +223,51 @@ export class Configuration {
|
|
|
203
223
|
return uri.replace(/\/events$/, "/spans");
|
|
204
224
|
}
|
|
205
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Whether an outbound request to `host` should carry a traceparent header; see
|
|
228
|
+
* propagateTraces/tracePropagationTargets above. Case-insensitive, since hostnames are.
|
|
229
|
+
* @param {string} host
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
propagatesTraceTo(host) {
|
|
233
|
+
if (!this.propagateTraces) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (this.tracePropagationTargets === null || this.tracePropagationTargets === undefined) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const normalized = String(host ?? "").toLowerCase();
|
|
240
|
+
return this.tracePropagationTargets.some((target) => {
|
|
241
|
+
if (target instanceof RegExp) {
|
|
242
|
+
target.lastIndex = 0; // a /g or /y pattern would otherwise carry state from the last test
|
|
243
|
+
return target.test(normalized);
|
|
244
|
+
}
|
|
245
|
+
const suffix = String(target).toLowerCase().replace(/^\./, "");
|
|
246
|
+
return suffix !== "" && (normalized === suffix || normalized.endsWith(`.${suffix}`));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether a request to `host`:`port` is one of this client's own deliveries to ForgeOps (same
|
|
252
|
+
* host and port as the DSN), so the outbound-HTTP wrapper never adds a traceparent to it. The
|
|
253
|
+
* Client itself uses fetch(), which that wrapper never sees, so this is a second guard, not the
|
|
254
|
+
* only one.
|
|
255
|
+
* @param {string} host
|
|
256
|
+
* @param {number | string | null | undefined} port
|
|
257
|
+
* @param {string} protocol "http:" or "https:"
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
isOwnHost(host, port, protocol) {
|
|
261
|
+
const parsed = this.#parsedDsn();
|
|
262
|
+
if (parsed === null || !host) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const defaultPort = (scheme) => (scheme === "http:" ? "80" : scheme === "https:" ? "443" : "");
|
|
266
|
+
const ownPort = parsed.port || defaultPort(parsed.protocol);
|
|
267
|
+
const targetPort = port === null || port === undefined || port === "" ? defaultPort(protocol) : String(port);
|
|
268
|
+
return String(host).toLowerCase() === parsed.hostname.toLowerCase() && targetPort === ownPort;
|
|
269
|
+
}
|
|
270
|
+
|
|
206
271
|
/** @returns {boolean} */
|
|
207
272
|
isEnabled() {
|
|
208
273
|
return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
|
package/src/eventBuilder.js
CHANGED
|
@@ -49,8 +49,14 @@ export class EventBuilder {
|
|
|
49
49
|
* @param {Record<string, unknown>} [context]
|
|
50
50
|
* @param {Record<string, unknown> | null} [user]
|
|
51
51
|
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
52
|
+
* @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request]
|
|
53
|
+
* Where the error happened (see RequestState in requestState.js): transactionName is the same
|
|
54
|
+
* "GET /orders/:id" name the request's root span and performance samples use, endpoint the
|
|
55
|
+
* human-readable route ("GET /orders/:id"), traceId the request's 32-character lowercase hex
|
|
56
|
+
* W3C trace id, which is also what links this error to errors other services reported for the
|
|
57
|
+
* same trace. All absent outside a request, and a missing one is left out of the payload.
|
|
52
58
|
*/
|
|
53
|
-
build(error, context = {}, user = null, breadcrumbs = []) {
|
|
59
|
+
build(error, context = {}, user = null, breadcrumbs = [], request = {}) {
|
|
54
60
|
const payload = {
|
|
55
61
|
exception_class: error?.name ?? "Error",
|
|
56
62
|
message: error?.message ?? "",
|
|
@@ -69,13 +75,24 @@ export class EventBuilder {
|
|
|
69
75
|
if (breadcrumbs && breadcrumbs.length > 0) {
|
|
70
76
|
payload.breadcrumbs = [...breadcrumbs];
|
|
71
77
|
}
|
|
78
|
+
if (request.transactionName) {
|
|
79
|
+
payload.transaction_name = request.transactionName;
|
|
80
|
+
}
|
|
81
|
+
if (request.endpoint) {
|
|
82
|
+
payload.endpoint = request.endpoint;
|
|
83
|
+
}
|
|
84
|
+
if (request.traceId) {
|
|
85
|
+
payload.trace_id = request.traceId;
|
|
86
|
+
}
|
|
72
87
|
this.#attachSql(payload, error);
|
|
73
88
|
|
|
74
89
|
return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
// exception_class/occurred_at/environment/release/server_name/user
|
|
78
|
-
// left alone
|
|
92
|
+
// exception_class/occurred_at/environment/release/server_name/user/
|
|
93
|
+
// transaction_name/endpoint/trace_id are left alone (endpoint is a
|
|
94
|
+
// declared route pattern, never the literal path, so there's no id or
|
|
95
|
+
// token in it to scrub): structured fields this client or the host app sets
|
|
79
96
|
// deliberately, not free text an exception or its context could
|
|
80
97
|
// accidentally spill sensitive data into. user specifically is a
|
|
81
98
|
// deliberate exemption, not an oversight: the scrubber's own email
|
package/src/httpTracing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
+
import { generateSpanId, TRACEPARENT_HEADER } from "./traceParent.js";
|
|
3
4
|
|
|
4
5
|
let installed = false;
|
|
5
6
|
let originalHttpRequest = null;
|
|
@@ -20,19 +21,28 @@ let originalHttpsRequest = null;
|
|
|
20
21
|
* recordSpan is passed in as a plain parameter rather than imported directly from index.js: this
|
|
21
22
|
* module gets installed FROM index.js's own init(), so importing index.js back from here would
|
|
22
23
|
* create a circular module dependency index.js has never needed before, for no real benefit over
|
|
23
|
-
* just passing the
|
|
24
|
+
* just passing the functions actually used.
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
+
* Also propagates the current request's trace to the service being called, as a W3C `traceparent`
|
|
27
|
+
* header (see traceParent.js, and Configuration#propagateTraces/tracePropagationTargets for turning
|
|
28
|
+
* it off or narrowing it to specific hosts). The header's parent id is this outbound call's own
|
|
29
|
+
* span id, generated before the call is made (the span itself can only be recorded afterward, once
|
|
30
|
+
* its duration is known) and then recorded with that exact id, so the downstream service's root
|
|
31
|
+
* span points at a span that really exists in this trace. traceparentFor decides whether there is
|
|
32
|
+
* a trace to continue at all (only inside a request) and returns null otherwise.
|
|
33
|
+
*
|
|
34
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>, spanId?: string) => void} recordSpan
|
|
35
|
+
* @param {(host: string, port: number | string | null | undefined, protocol: string, spanId: string) => string | null} [traceparentFor]
|
|
26
36
|
*/
|
|
27
|
-
export function installHttpTracing(recordSpan) {
|
|
37
|
+
export function installHttpTracing(recordSpan, traceparentFor = () => null) {
|
|
28
38
|
if (installed) {
|
|
29
39
|
return;
|
|
30
40
|
}
|
|
31
41
|
installed = true;
|
|
32
42
|
originalHttpRequest = http.request;
|
|
33
43
|
originalHttpsRequest = https.request;
|
|
34
|
-
http.request = wrap(originalHttpRequest, recordSpan);
|
|
35
|
-
https.request = wrap(originalHttpsRequest, recordSpan);
|
|
44
|
+
http.request = wrap(originalHttpRequest, recordSpan, traceparentFor, "http:");
|
|
45
|
+
https.request = wrap(originalHttpsRequest, recordSpan, traceparentFor, "https:");
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** @internal test-only: restores http/https.request exactly as installHttpTracing found them. */
|
|
@@ -47,12 +57,14 @@ export function _uninstallHttpTracing() {
|
|
|
47
57
|
originalHttpsRequest = null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
function wrap(originalRequest, recordSpan) {
|
|
60
|
+
function wrap(originalRequest, recordSpan, traceparentFor, defaultProtocol) {
|
|
51
61
|
return function patchedRequest(...args) {
|
|
62
|
+
const spanId = generateSpanId();
|
|
52
63
|
const startedAt = new Date();
|
|
53
64
|
const start = performance.now();
|
|
54
65
|
const req = originalRequest.apply(this, args);
|
|
55
|
-
const { method, host } = describeRequest(args);
|
|
66
|
+
const { method, host, port, protocol } = describeRequest(args, defaultProtocol);
|
|
67
|
+
propagateTrace(req, () => traceparentFor(host, port, protocol, spanId));
|
|
56
68
|
|
|
57
69
|
// "response" and "error" are mutually exclusive on a real ClientRequest, but guarded anyway:
|
|
58
70
|
// recording twice for the same outbound call would double it up in the waterfall.
|
|
@@ -62,7 +74,7 @@ function wrap(originalRequest, recordSpan) {
|
|
|
62
74
|
return;
|
|
63
75
|
}
|
|
64
76
|
finished = true;
|
|
65
|
-
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status });
|
|
77
|
+
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status }, spanId);
|
|
66
78
|
};
|
|
67
79
|
|
|
68
80
|
// No status at all means the request itself failed (DNS, connection refused, timeout) before
|
|
@@ -75,6 +87,31 @@ function wrap(originalRequest, recordSpan) {
|
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Adds the traceparent header to a request that was just created and hasn't sent its headers yet
|
|
92
|
+
* (the caller only writes or ends it after http.request returns). Leaves a traceparent the caller
|
|
93
|
+
* already set alone: whoever set it explicitly knows better than this client which trace the call
|
|
94
|
+
* belongs to. Skipped when headers were already flushed (a raw-array `headers` option sends them at
|
|
95
|
+
* construction). Never throws, since a failure to add a header must never stop the host app's own
|
|
96
|
+
* request from going out.
|
|
97
|
+
*
|
|
98
|
+
* @param {import("node:http").ClientRequest} req
|
|
99
|
+
* @param {() => string | null} traceparent
|
|
100
|
+
*/
|
|
101
|
+
function propagateTrace(req, traceparent) {
|
|
102
|
+
try {
|
|
103
|
+
if (req.headersSent || req.hasHeader(TRACEPARENT_HEADER)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const value = traceparent();
|
|
107
|
+
if (value !== null) {
|
|
108
|
+
req.setHeader(TRACEPARENT_HEADER, value);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// See this function's own comment: the request goes out without the header.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Best-effort parse of whatever calling convention produced this request (a bare options object,
|
|
80
117
|
* a URL string/object plus an optional options object, either form optionally followed by a
|
|
@@ -82,15 +119,21 @@ function wrap(originalRequest, recordSpan) {
|
|
|
82
119
|
* path or query string a customer's own request carries (that could hold a customer's own id or
|
|
83
120
|
* a secret), the same reasoning every other transaction_name/span name in this client already
|
|
84
121
|
* follows.
|
|
122
|
+
*
|
|
123
|
+
* port/protocol are only ever used to recognize this client's own deliveries to ForgeOps (see
|
|
124
|
+
* Configuration#isOwnHost), never in a label.
|
|
85
125
|
* @param {unknown[]} args
|
|
126
|
+
* @param {string} defaultProtocol
|
|
86
127
|
*/
|
|
87
|
-
function describeRequest(args) {
|
|
128
|
+
function describeRequest(args, defaultProtocol) {
|
|
88
129
|
const [first, second] = args;
|
|
89
130
|
const options = {};
|
|
90
131
|
|
|
91
132
|
if (typeof first === "string" || first instanceof URL) {
|
|
92
133
|
const url = typeof first === "string" ? new URL(first) : first;
|
|
93
134
|
options.host = url.hostname;
|
|
135
|
+
options.port = url.port;
|
|
136
|
+
options.protocol = url.protocol;
|
|
94
137
|
if (second && typeof second === "object") {
|
|
95
138
|
Object.assign(options, second);
|
|
96
139
|
}
|
|
@@ -100,5 +143,5 @@ function describeRequest(args) {
|
|
|
100
143
|
|
|
101
144
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
102
145
|
const host = options.host ?? options.hostname ?? "unknown";
|
|
103
|
-
return { method, host };
|
|
146
|
+
return { method, host, port: options.port, protocol: options.protocol ?? defaultProtocol };
|
|
104
147
|
}
|
package/src/index.js
CHANGED
|
@@ -8,8 +8,10 @@ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
|
|
|
8
8
|
import { MetricBuffer } from "./metricBuffer.js";
|
|
9
9
|
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
10
10
|
import { Reporter } from "./reporter.js";
|
|
11
|
+
import { requestStateFor } from "./requestState.js";
|
|
11
12
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
12
13
|
import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
|
|
14
|
+
import { buildTraceparent } from "./traceParent.js";
|
|
13
15
|
|
|
14
16
|
export { Configuration };
|
|
15
17
|
export { withSql } from "./sqlStatement.js";
|
|
@@ -78,6 +80,14 @@ const spanStorage = new AsyncLocalStorage();
|
|
|
78
80
|
// SpanBuffer at all" as the real signal for "not inside a traced request").
|
|
79
81
|
const spanParentStorage = new AsyncLocalStorage();
|
|
80
82
|
|
|
83
|
+
// Request-scoped RequestState (trace id, remote parent span id, transaction name, endpoint, errored
|
|
84
|
+
// flag; see requestState.js), published by the Express/Fastify integrations for the duration of a
|
|
85
|
+
// request through _runWithRequestState below. Not gated on trackTracing: the trace id links an
|
|
86
|
+
// error to errors in other services even when no spans are ever recorded. Unset outside a request,
|
|
87
|
+
// where captureException, span tracing, and the outbound-HTTP wrapper all behave exactly as they
|
|
88
|
+
// did before this existed.
|
|
89
|
+
const requestStorage = new AsyncLocalStorage();
|
|
90
|
+
|
|
81
91
|
function getConfiguration() {
|
|
82
92
|
if (configuration === null) {
|
|
83
93
|
configuration = new Configuration();
|
|
@@ -242,6 +252,64 @@ export function _runWithBreadcrumbTrail(callback) {
|
|
|
242
252
|
return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Internal; called by the Express/Fastify integrations with their own request object and its
|
|
257
|
+
* incoming traceparent header's raw value. Returns the RequestState memoized on that request (see
|
|
258
|
+
* requestState.js), continuing the incoming trace or starting a fresh one, or null when the client
|
|
259
|
+
* isn't enabled at all, the same "skip entirely" gating every other automatic source applies.
|
|
260
|
+
*
|
|
261
|
+
* @param {object} request an Express req or a Fastify request
|
|
262
|
+
* @param {unknown} traceparent
|
|
263
|
+
* @returns {import("./requestState.js").RequestState | null}
|
|
264
|
+
*/
|
|
265
|
+
export function _requestStateFor(request, traceparent) {
|
|
266
|
+
if (!getConfiguration().isEnabled()) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return requestStateFor(request, traceparent);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Internal; runs callback with `state` published as the current request's for its whole async
|
|
274
|
+
* call chain. Just calls callback when state is null, or already the current one (two integrations
|
|
275
|
+
* wrapping the same request), so it never nests a second identical context.
|
|
276
|
+
*
|
|
277
|
+
* @template T
|
|
278
|
+
* @param {import("./requestState.js").RequestState | null} state
|
|
279
|
+
* @param {() => T} callback
|
|
280
|
+
* @returns {T}
|
|
281
|
+
*/
|
|
282
|
+
export function _runWithRequestState(state, callback) {
|
|
283
|
+
if (!state || requestStorage.getStore() === state) {
|
|
284
|
+
return callback();
|
|
285
|
+
}
|
|
286
|
+
return requestStorage.run(state, callback);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Internal; called by the outbound-HTTP wrapper (httpTracing.js) just before a request to
|
|
291
|
+
* `host`:`port` goes out. Returns the traceparent header value naming `spanId` (that request's own
|
|
292
|
+
* span) as the parent, or null when this request shouldn't carry one: outside a request, with
|
|
293
|
+
* propagateTraces off or `host` not a propagation target, or for a delivery to ForgeOps itself.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} host
|
|
296
|
+
* @param {number | string | null | undefined} port
|
|
297
|
+
* @param {string} protocol
|
|
298
|
+
* @param {string} spanId
|
|
299
|
+
* @returns {string | null}
|
|
300
|
+
*/
|
|
301
|
+
export function _traceparentFor(host, port, protocol, spanId) {
|
|
302
|
+
const state = requestStorage.getStore();
|
|
303
|
+
if (!state) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const config = getConfiguration();
|
|
307
|
+
if (config.isOwnHost(host, port, protocol) || !config.propagatesTraceTo(host)) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return buildTraceparent(state.traceId, spanId);
|
|
311
|
+
}
|
|
312
|
+
|
|
245
313
|
/**
|
|
246
314
|
* Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
|
|
247
315
|
* by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
|
|
@@ -261,7 +329,10 @@ export function _runWithSpanTrace(callback) {
|
|
|
261
329
|
if (!config.trackTracing || !config.isEnabled()) {
|
|
262
330
|
return callback();
|
|
263
331
|
}
|
|
264
|
-
|
|
332
|
+
// The trace id and remote parent come from the request's RequestState (see _runWithRequestState
|
|
333
|
+
// above), so this trace and any error event from the same request agree on the trace.
|
|
334
|
+
const state = requestStorage.getStore();
|
|
335
|
+
const buffer = new SpanBuffer(config, state ? { traceId: state.traceId, parentSpanId: state.parentSpanId } : {});
|
|
265
336
|
return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
|
|
266
337
|
}
|
|
267
338
|
|
|
@@ -285,7 +356,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
285
356
|
return;
|
|
286
357
|
}
|
|
287
358
|
buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
|
|
288
|
-
|
|
359
|
+
// An errored request's trace is always sent too, however fast it was, since the waterfall of
|
|
360
|
+
// what led up to an error is exactly what an issue page wants to show next to it. "Errored" means
|
|
361
|
+
// captureException ran during the request (the Express/Fastify error integrations call it too).
|
|
362
|
+
const errored = requestStorage.getStore()?.errored === true;
|
|
363
|
+
if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs) || (errored && buffer.rootRecorded)) {
|
|
289
364
|
getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
|
|
290
365
|
}
|
|
291
366
|
}
|
|
@@ -303,15 +378,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
303
378
|
* @param {Date} startedAt
|
|
304
379
|
* @param {number} durationMs
|
|
305
380
|
* @param {Record<string, unknown>} [data]
|
|
381
|
+
* @param {string} [spanId] only passed by the outbound-HTTP wrapper, which has to pick it before
|
|
382
|
+
* the call goes out so the traceparent header it sends names this exact span
|
|
306
383
|
*/
|
|
307
|
-
export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
|
|
384
|
+
export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
|
|
308
385
|
const config = getConfiguration();
|
|
309
386
|
const buffer = spanStorage.getStore();
|
|
310
387
|
if (!config.trackTracing || !buffer) {
|
|
311
388
|
return;
|
|
312
389
|
}
|
|
313
390
|
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
314
|
-
buffer.record({ spanId
|
|
391
|
+
buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
|
|
315
392
|
}
|
|
316
393
|
|
|
317
394
|
/**
|
|
@@ -363,7 +440,7 @@ export function init(options = {}) {
|
|
|
363
440
|
// gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
|
|
364
441
|
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
365
442
|
// _recordSpan above), never at install time.
|
|
366
|
-
installHttpTracing(_recordSpan);
|
|
443
|
+
installHttpTracing(_recordSpan, _traceparentFor);
|
|
367
444
|
|
|
368
445
|
return config;
|
|
369
446
|
}
|
|
@@ -373,13 +450,20 @@ export function init(options = {}) {
|
|
|
373
450
|
* established for this async call chain, if anything; pass one explicitly to override that for
|
|
374
451
|
* this one report.
|
|
375
452
|
*
|
|
453
|
+
* Inside a request the Express/Fastify integrations are handling, the event also carries the
|
|
454
|
+
* request's transaction_name, endpoint, and trace_id, and the request's trace is sent however fast
|
|
455
|
+
* it was.
|
|
456
|
+
*
|
|
376
457
|
* @param {Error} error
|
|
377
458
|
* @param {Record<string, unknown>} [context]
|
|
378
459
|
* @param {Record<string, unknown> | null} [user]
|
|
379
460
|
*/
|
|
380
461
|
export function captureException(error, context = {}, user = null) {
|
|
381
462
|
const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
|
|
382
|
-
|
|
463
|
+
const state = requestStorage.getStore();
|
|
464
|
+
state?.markErrored();
|
|
465
|
+
const request = state ? { transactionName: state.transactionName, endpoint: state.endpoint, traceId: state.traceId } : {};
|
|
466
|
+
getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs, request);
|
|
383
467
|
}
|
|
384
468
|
|
|
385
469
|
/**
|
|
@@ -565,6 +649,7 @@ export function _resetForTesting() {
|
|
|
565
649
|
// sharing the same root context. disable() drops it and leaves the instance perfectly usable
|
|
566
650
|
// again for the next run()/enterWith() call, confirmed directly, not assumed.
|
|
567
651
|
breadcrumbStorage.disable();
|
|
652
|
+
requestStorage.disable();
|
|
568
653
|
// Re-installed fresh on the very next init() call in whatever test runs next: without this,
|
|
569
654
|
// http.request/https.request would stay wrapped by a closure over a *previous* test's own
|
|
570
655
|
// _recordSpan reference forever (installHttpTracing's own guard only ever installs once per
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { captureException, runWithUser } from "../index.js";
|
|
1
|
+
import { _requestStateFor, _runWithRequestState, captureException, runWithUser } from "../index.js";
|
|
2
|
+
import { describeExpressRoute } from "./tracing.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Express error-handling middleware. Register last, after all routes:
|
|
@@ -12,6 +13,12 @@ import { captureException, runWithUser } from "../index.js";
|
|
|
12
13
|
* async route handlers to error-handling middleware automatically,
|
|
13
14
|
* verified directly against a real async handler, not assumed. Only an
|
|
14
15
|
* exception your own code catches and handles is invisible to this.
|
|
16
|
+
*
|
|
17
|
+
* The event carries the request's trace id, transaction name, and
|
|
18
|
+
* endpoint: the RequestState forgeOpsTrackerTracingExpressMiddleware
|
|
19
|
+
* already stashed on req when that's installed, or one created here from
|
|
20
|
+
* the request's own traceparent header when it isn't, so an unhandled
|
|
21
|
+
* error is linked across services either way.
|
|
15
22
|
*/
|
|
16
23
|
export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
|
|
17
24
|
// See integrations/sessionTracking.js's own comment: marks the request crashed before
|
|
@@ -20,10 +27,14 @@ export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
|
|
|
20
27
|
// the time it checks.
|
|
21
28
|
req._forgeOpsSessionCrashed = true;
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
const state = _requestStateFor(req, req.headers?.traceparent);
|
|
31
|
+
state?.describeWith(() => describeExpressRoute(req));
|
|
32
|
+
_runWithRequestState(state, () =>
|
|
33
|
+
captureException(err, {
|
|
34
|
+
path: req.path,
|
|
35
|
+
method: req.method,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
27
38
|
next(err);
|
|
28
39
|
}
|
|
29
40
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { captureException } from "../index.js";
|
|
1
|
+
import { _requestStateFor, _runWithRequestState, captureException } from "../index.js";
|
|
2
|
+
import { fastifyRoutePattern } from "./tracing.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Called directly with your Fastify instance: not registered via
|
|
@@ -15,13 +16,25 @@ import { captureException } from "../index.js";
|
|
|
15
16
|
* continues exactly as if this hook weren't registered. Only an
|
|
16
17
|
* exception your own code catches and handles is invisible to this.
|
|
17
18
|
*
|
|
19
|
+
* The event carries the request's trace id, transaction name, and
|
|
20
|
+
* endpoint: the RequestState registerForgeOpsTrackerTracing already
|
|
21
|
+
* created for this request when that's registered, or one created here
|
|
22
|
+
* from the request's own traceparent header when it isn't.
|
|
23
|
+
*
|
|
18
24
|
* @param {import("fastify").FastifyInstance} fastify
|
|
19
25
|
*/
|
|
20
26
|
export function registerForgeOpsTracker(fastify) {
|
|
21
27
|
fastify.addHook("onError", async (request, reply, error) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const state = _requestStateFor(request, request.headers?.traceparent);
|
|
29
|
+
const route = fastifyRoutePattern(request);
|
|
30
|
+
if (state && route && state.transactionName === null) {
|
|
31
|
+
state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
|
|
32
|
+
}
|
|
33
|
+
_runWithRequestState(state, () =>
|
|
34
|
+
captureException(error, {
|
|
35
|
+
path: request.url,
|
|
36
|
+
method: request.method,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
26
39
|
});
|
|
27
40
|
}
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
|
|
1
|
+
import { _finishSpanTrace, _requestStateFor, _runWithRequestState, _runWithSpanTrace } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How an Express request is named once a route has matched: transactionName the same
|
|
5
|
+
* "<HTTP method> <route pattern>" the root span and performance samples use, endpoint the same
|
|
6
|
+
* route pattern. req.route.path is the pattern as declared on the router that matched it; for a
|
|
7
|
+
* router mounted with app.use("/api", router), that's the part declared on the router, without
|
|
8
|
+
* "/api": req.baseUrl is the literal matched mount path, which for a parameterized mount
|
|
9
|
+
* ("/users/:userId") would put a real id into a field that must never carry one. null before a
|
|
10
|
+
* route has matched (the event then leaves both fields out).
|
|
11
|
+
*
|
|
12
|
+
* @param {import("express").Request} req
|
|
13
|
+
*/
|
|
14
|
+
export function describeExpressRoute(req) {
|
|
15
|
+
if (!req.route) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const name = `${req.method} ${req.route.path}`;
|
|
19
|
+
return { transactionName: name, endpoint: name };
|
|
20
|
+
}
|
|
2
21
|
|
|
3
22
|
/**
|
|
4
23
|
* Express distributed-tracing middleware: wraps the rest of the request in _runWithSpanTrace
|
|
@@ -20,20 +39,33 @@ import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
|
|
|
20
39
|
* actually finished and the root span's real total duration is known: nothing about a normal,
|
|
21
40
|
* fast request costs a single byte over the wire, matching gems/forge_ops_tracker's own
|
|
22
41
|
* Middleware::SpanTracing.
|
|
42
|
+
*
|
|
43
|
+
* Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
|
|
44
|
+
* on, so every request gets a trace id, continuing an incoming W3C traceparent header when there
|
|
45
|
+
* is one (its root span's parent is then the caller's span), and an error captured anywhere in
|
|
46
|
+
* the request carries it plus the matched route. Register it before your routes for that: an
|
|
47
|
+
* error thrown by a route registered ahead of it never sees this request's trace. Express only
|
|
48
|
+
* sets req.route once the matched handler starts, so the route is read lazily, whenever an error
|
|
49
|
+
* needs it (see RequestState#describeWith).
|
|
23
50
|
*/
|
|
24
51
|
export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const state = _requestStateFor(req, req.headers?.traceparent);
|
|
53
|
+
state?.describeWith(() => describeExpressRoute(req));
|
|
54
|
+
|
|
55
|
+
_runWithRequestState(state, () =>
|
|
56
|
+
_runWithSpanTrace(() => {
|
|
57
|
+
const startedAt = new Date();
|
|
58
|
+
const start = performance.now();
|
|
28
59
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
res.on("finish", () => {
|
|
61
|
+
const durationMs = performance.now() - start;
|
|
62
|
+
const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
|
|
63
|
+
_finishSpanTrace(transactionName, startedAt, durationMs);
|
|
64
|
+
});
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
next();
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
/**
|
|
@@ -55,15 +87,29 @@ export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
|
|
|
55
87
|
* matched a route at all (a 404), the same "pattern first, literal path as the 404 fallback"
|
|
56
88
|
* shape the Express integration above already takes.
|
|
57
89
|
*
|
|
90
|
+
* Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
|
|
91
|
+
* on, the same as the Express middleware above: a trace id for every request, continuing an
|
|
92
|
+
* incoming W3C traceparent header. Fastify has already matched the route by onRequest, so the
|
|
93
|
+
* transaction name and endpoint are set right here, before any handler runs, and only from the
|
|
94
|
+
* matched pattern: a request that matched no route (a 404) leaves both out.
|
|
95
|
+
*
|
|
58
96
|
* @param {import("fastify").FastifyInstance} fastify
|
|
59
97
|
*/
|
|
60
98
|
export function registerForgeOpsTrackerTracing(fastify) {
|
|
61
99
|
fastify.addHook("onRequest", (request, reply, done) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
100
|
+
const state = _requestStateFor(request, request.headers?.traceparent);
|
|
101
|
+
const route = fastifyRoutePattern(request);
|
|
102
|
+
if (state && route) {
|
|
103
|
+
state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_runWithRequestState(state, () =>
|
|
107
|
+
_runWithSpanTrace(() => {
|
|
108
|
+
request._forgeOpsSpanStartedAt = new Date();
|
|
109
|
+
request._forgeOpsSpanStart = performance.now();
|
|
110
|
+
done();
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
67
113
|
});
|
|
68
114
|
|
|
69
115
|
fastify.addHook("onResponse", async (request) => {
|
|
@@ -78,3 +124,14 @@ export function registerForgeOpsTrackerTracing(fastify) {
|
|
|
78
124
|
_finishSpanTrace(transactionName, request._forgeOpsSpanStartedAt, durationMs);
|
|
79
125
|
});
|
|
80
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fastify's matched route pattern ("/orders/:id", prefixes included), or undefined for a request
|
|
130
|
+
* that matched no route: request.routeOptions.url, falling back to the older request.routerPath.
|
|
131
|
+
*
|
|
132
|
+
* @param {import("fastify").FastifyRequest} request
|
|
133
|
+
* @returns {string | undefined}
|
|
134
|
+
*/
|
|
135
|
+
export function fastifyRoutePattern(request) {
|
|
136
|
+
return request.routeOptions?.url ?? request.routerPath ?? undefined;
|
|
137
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -22,14 +22,15 @@ export class Reporter {
|
|
|
22
22
|
* @param {Record<string, unknown>} [context]
|
|
23
23
|
* @param {Record<string, unknown> | null} [user]
|
|
24
24
|
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
25
|
+
* @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request] see EventBuilder#build
|
|
25
26
|
*/
|
|
26
|
-
report(error, context = {}, user = null, breadcrumbs = []) {
|
|
27
|
+
report(error, context = {}, user = null, breadcrumbs = [], request = {}) {
|
|
27
28
|
try {
|
|
28
29
|
if (!this.#configuration.isEnabled()) {
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const payload = this.#eventBuilder.build(error, context, user, breadcrumbs);
|
|
33
|
+
const payload = this.#eventBuilder.build(error, context, user, breadcrumbs, request);
|
|
33
34
|
this.#deliveryQueue.push(payload);
|
|
34
35
|
} catch (e) {
|
|
35
36
|
this.#configuration.log(`[forge-ops-tracker] report failed: ${e.name}: ${e.message}`);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { generateTraceId, parseTraceparent } from "./traceParent.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything this client knows about the current request that isn't already owned by one of the
|
|
5
|
+
* older, single-purpose AsyncLocalStorage instances (the user, the breadcrumb trail, the span
|
|
6
|
+
* buffer): which trace it belongs to, which remote span called it (if any), what it's called, and
|
|
7
|
+
* whether it errored. One instance per request, published by the Express/Fastify integrations
|
|
8
|
+
* (see _runWithRequestState in index.js) so captureException, the tracing integration, and the
|
|
9
|
+
* outbound-HTTP wrapper can all reach it. Ported from
|
|
10
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/request_state.rb.
|
|
11
|
+
*
|
|
12
|
+
* The traceId exists for every request, not only when trackTracing is on: it's also what an error
|
|
13
|
+
* event carries so ForgeOps can link it to errors reported by other services taking part in the
|
|
14
|
+
* same trace, which works on any plan and with tracing off entirely.
|
|
15
|
+
*
|
|
16
|
+
* Also stashed on the framework's own request object (see requestStateFor), the Node equivalent of
|
|
17
|
+
* the Ruby gem memoizing it in the Rack env: an error-handling middleware that runs outside the
|
|
18
|
+
* tracing middleware's async context still finds the same instance there.
|
|
19
|
+
*/
|
|
20
|
+
export class RequestState {
|
|
21
|
+
traceId;
|
|
22
|
+
parentSpanId;
|
|
23
|
+
errored = false;
|
|
24
|
+
#describe;
|
|
25
|
+
#transactionName = null;
|
|
26
|
+
#endpoint = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
|
|
30
|
+
*/
|
|
31
|
+
constructor({ traceId = generateTraceId(), parentSpanId = null } = {}) {
|
|
32
|
+
this.traceId = traceId;
|
|
33
|
+
this.parentSpanId = parentSpanId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Continues the caller's trace when the request arrived with a usable W3C traceparent header,
|
|
38
|
+
* remembering the caller's span as this request's remote parent; starts a fresh trace otherwise.
|
|
39
|
+
* @param {unknown} traceparent
|
|
40
|
+
*/
|
|
41
|
+
static fromTraceparent(traceparent) {
|
|
42
|
+
const incoming = parseTraceparent(traceparent);
|
|
43
|
+
return incoming === null ? new RequestState() : new RequestState(incoming);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How to name this request, read lazily, each time an error needs it: Express only sets req.route
|
|
48
|
+
* once the matched route's own handler starts running, with no app-level hook in between, so the
|
|
49
|
+
* integration hands over a function that reads it then instead of a name it can't know yet at
|
|
50
|
+
* request start. Returns null (and the event leaves both fields out) until a route has matched.
|
|
51
|
+
* @param {() => { transactionName: string, endpoint: string } | null} describe
|
|
52
|
+
*/
|
|
53
|
+
describeWith(describe) {
|
|
54
|
+
this.#describe = describe;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sets both names outright, for a framework that already knows the matched route when the
|
|
59
|
+
* request starts (Fastify's onRequest).
|
|
60
|
+
* @param {string} transactionName
|
|
61
|
+
* @param {string} endpoint
|
|
62
|
+
*/
|
|
63
|
+
setRoute(transactionName, endpoint) {
|
|
64
|
+
this.#transactionName = transactionName;
|
|
65
|
+
this.#endpoint = endpoint;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {string | null} */
|
|
69
|
+
get transactionName() {
|
|
70
|
+
return this.#transactionName ?? this.#described()?.transactionName ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @returns {string | null} */
|
|
74
|
+
get endpoint() {
|
|
75
|
+
return this.#endpoint ?? this.#described()?.endpoint ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
markErrored() {
|
|
79
|
+
this.errored = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#described() {
|
|
83
|
+
try {
|
|
84
|
+
return this.#describe?.() ?? null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REQUEST_STATE_KEY = Symbol.for("forge-ops-tracker.requestState");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The RequestState memoized on `request` (an Express req or a Fastify request), created from its
|
|
95
|
+
* traceparent header on first use: whichever integration reaches a request first creates it, and
|
|
96
|
+
* every other one gets the identical instance back, so they all agree on one trace id. A symbol
|
|
97
|
+
* key, not a plain property: invisible to the app's own enumeration and serialization of req.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} request
|
|
100
|
+
* @param {unknown} traceparent the incoming traceparent header's raw value
|
|
101
|
+
* @returns {RequestState}
|
|
102
|
+
*/
|
|
103
|
+
export function requestStateFor(request, traceparent) {
|
|
104
|
+
if (request[REQUEST_STATE_KEY] === undefined) {
|
|
105
|
+
request[REQUEST_STATE_KEY] = RequestState.fromTraceparent(traceparent);
|
|
106
|
+
}
|
|
107
|
+
return request[REQUEST_STATE_KEY];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The RequestState already memoized on `request`, if any integration created one.
|
|
112
|
+
* @param {object} request
|
|
113
|
+
* @returns {RequestState | undefined}
|
|
114
|
+
*/
|
|
115
|
+
export function existingRequestState(request) {
|
|
116
|
+
return request?.[REQUEST_STATE_KEY];
|
|
117
|
+
}
|
package/src/spanBuffer.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generateSpanId, generateTraceId } from "./traceParent.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A short, unique-enough identifier for one span:
|
|
5
|
-
*
|
|
4
|
+
* A short, unique-enough identifier for one span: a W3C span id (16 lowercase hex characters,
|
|
5
|
+
* never all zeros; see traceParent.js), the same id a traceparent header's parent-id carries. The
|
|
6
6
|
* server only ever needs these to be unique within one trace's own array (see
|
|
7
7
|
* Api::V1::SpansController on the Rails side), never a real database id, so this is deliberately
|
|
8
8
|
* cheap rather than a full UUID.
|
|
9
9
|
* @returns {string}
|
|
10
10
|
*/
|
|
11
11
|
export function randomSpanId() {
|
|
12
|
-
return
|
|
12
|
+
return generateSpanId();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -33,17 +33,31 @@ export class SpanBuffer {
|
|
|
33
33
|
rootSpanId;
|
|
34
34
|
spans = [];
|
|
35
35
|
#rootDurationMs = null;
|
|
36
|
+
#remoteParentSpanId;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* traceId/parentSpanId come from the request's own state (see _runWithRequestState in index.js)
|
|
40
|
+
* when there is one, never generated separately here, so a trace this sends and an error event
|
|
41
|
+
* from the same request always agree on which trace they belong to. parentSpanId is the calling
|
|
42
|
+
* service's span (from an incoming traceparent header), recorded as the root span's parent: the
|
|
43
|
+
* server treats a parent that isn't in this trace's own batch as remote, and nests this
|
|
44
|
+
* service's root under the caller's outgoing HTTP span.
|
|
45
|
+
*
|
|
46
|
+
* @param {import("./configuration.js").Configuration} configuration
|
|
47
|
+
* @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
|
|
48
|
+
*/
|
|
49
|
+
constructor(configuration, { traceId = generateTraceId(), parentSpanId = null } = {}) {
|
|
38
50
|
this.#configuration = configuration;
|
|
39
|
-
this.traceId =
|
|
51
|
+
this.traceId = traceId;
|
|
40
52
|
this.rootSpanId = randomSpanId();
|
|
53
|
+
this.#remoteParentSpanId = parentSpanId;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* root: true only for the one call that records the request's own root span: forces
|
|
45
|
-
* parent_span_id null
|
|
46
|
-
*
|
|
58
|
+
* parent_span_id to the remote parent (null unless the request arrived with a traceparent
|
|
59
|
+
* header) explicitly, the same reasoning span_buffer.rb's own #record documents for why that
|
|
60
|
+
* can't just be read off of "whatever's currently open" for the root specifically.
|
|
47
61
|
*
|
|
48
62
|
* @param {{
|
|
49
63
|
* spanId: string,
|
|
@@ -59,7 +73,7 @@ export class SpanBuffer {
|
|
|
59
73
|
record({ spanId, parentSpanId = null, name, kind, startedAt, durationMs, data = {}, root = false }) {
|
|
60
74
|
this.spans.push({
|
|
61
75
|
span_id: spanId,
|
|
62
|
-
parent_span_id: root ?
|
|
76
|
+
parent_span_id: root ? this.#remoteParentSpanId : parentSpanId,
|
|
63
77
|
name,
|
|
64
78
|
kind,
|
|
65
79
|
// Date#toISOString() already yields millisecond precision UTC ("...sssZ"), exactly the wire
|
|
@@ -76,6 +90,15 @@ export class SpanBuffer {
|
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Whether the request's own root span has been recorded: an errored request's trace is only sent
|
|
95
|
+
* once it has, since spans with no root to hang from would render as a waterfall with no top.
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
get rootRecorded() {
|
|
99
|
+
return this.#rootDurationMs !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/**
|
|
80
103
|
* null (never sent) until the root span has actually been recorded: a request whose own
|
|
81
104
|
* tracing integration never got the chance to call back at all has no duration to compare
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads and writes the W3C Trace Context `traceparent` header
|
|
5
|
+
* (https://www.w3.org/TR/trace-context/), the vendor-neutral format for carrying one trace across
|
|
6
|
+
* service boundaries: `00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>`. Used in both
|
|
7
|
+
* directions: the Express/Fastify integrations parse an incoming one so a request continues the
|
|
8
|
+
* caller's trace instead of starting its own, and httpTracing.js builds an outgoing one so the next
|
|
9
|
+
* service along continues this request's. Ported from
|
|
10
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/trace_parent.rb.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately strict on the way in, the same posture the spec itself asks receivers to take: a
|
|
13
|
+
* malformed value, uppercase hex, the reserved version "ff", or an all-zero trace/parent id are all
|
|
14
|
+
* treated as "no usable header at all" (parseTraceparent returns null and the request starts a
|
|
15
|
+
* fresh trace), never half-trusted. A version this client doesn't know yet is still accepted as
|
|
16
|
+
* long as its first four fields have version 00's shape, which is exactly what the spec says a
|
|
17
|
+
* version-00 parser should do with a future version; version 00 itself must have exactly four
|
|
18
|
+
* fields.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const TRACEPARENT_HEADER = "traceparent";
|
|
22
|
+
|
|
23
|
+
const PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/s;
|
|
24
|
+
const INVALID_TRACE_ID = "0".repeat(32);
|
|
25
|
+
const INVALID_PARENT_ID = "0".repeat(16);
|
|
26
|
+
|
|
27
|
+
// Always "01" (sampled) on the way out: this client decides whether to actually send a trace only
|
|
28
|
+
// once the request is over (see _finishSpanTrace in index.js), long after this header has already
|
|
29
|
+
// gone out on an outbound call, so there's no honest earlier answer to give than "this may be
|
|
30
|
+
// recorded." A downstream service is free to make its own decision either way.
|
|
31
|
+
const SAMPLED_FLAGS = "01";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} value an incoming header's raw value
|
|
35
|
+
* @returns {{ traceId: string, parentSpanId: string } | null} null for anything that isn't a usable header
|
|
36
|
+
*/
|
|
37
|
+
export function parseTraceparent(value) {
|
|
38
|
+
if (typeof value !== "string") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const match = PATTERN.exec(value.trim());
|
|
42
|
+
if (match === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const [, version, traceId, parentSpanId, , rest] = match;
|
|
46
|
+
if (version === "ff" || (version === "00" && rest !== undefined)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (traceId === INVALID_TRACE_ID || parentSpanId === INVALID_PARENT_ID) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { traceId, parentSpanId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} traceId
|
|
57
|
+
* @param {string} spanId
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function buildTraceparent(traceId, spanId) {
|
|
61
|
+
return `00-${traceId}-${spanId}-${SAMPLED_FLAGS}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 32 lowercase hex characters, the W3C trace-id format, never all zeros (the spec reserves that as
|
|
66
|
+
* invalid, and a receiver drops the whole header over it): vanishingly unlikely from 16 random
|
|
67
|
+
* bytes, but free to rule out.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function generateTraceId() {
|
|
71
|
+
return randomId(16);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 16 lowercase hex characters, the W3C parent-id (span id) format, never all zeros.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function generateSpanId() {
|
|
79
|
+
return randomId(8);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function randomId(bytes) {
|
|
83
|
+
let id = randomBytes(bytes).toString("hex");
|
|
84
|
+
while (/^0+$/.test(id)) {
|
|
85
|
+
id = randomBytes(bytes).toString("hex");
|
|
86
|
+
}
|
|
87
|
+
return id;
|
|
88
|
+
}
|