@forge-ops/tracker 0.9.1 → 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 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,11 +437,89 @@ 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
419
495
  span for now.
420
496
 
497
+ ## Database errors
498
+
499
+ When an error carries the SQL behind a failed database call, the event includes the names of the stored procedure, table and view that SQL touched, so the issue tells you where to start looking. This is on by default and sends identifiers only, never values. The statement is read from a `.sql` or `.query` string on the error or anything it wraps (`cause`, and Sequelize's `parent`/`original`), which covers Sequelize, mysql2 and TypeORM. node-postgres, better-sqlite3 and Prisma errors carry none, so attach it where you ran the query with `withSql`.
500
+
501
+ To also send the SQL statement itself, opt in. Every string and number is replaced by `?` before it
502
+ leaves your process (`WHERE email = 'a@b.co' AND id = 42` is sent as `WHERE email = ? AND id = ?`),
503
+ and ForgeOps masks it again on arrival:
504
+
505
+ ```js
506
+ import { withSql } from "@forge-ops/tracker";
507
+
508
+ try {
509
+ await pool.query(sql, params);
510
+ } catch (error) {
511
+ throw withSql(error, sql);
512
+ }
513
+
514
+ // Opt in to also sending the masked statement (default false).
515
+ forgeOpsTracker.init({ dsn: "...", captureSqlStatement: true });
516
+ ```
517
+
518
+ Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off
519
+ there and the statement is never stored for that project, whatever this flag says; the names are
520
+ still kept. A view and a table are written the same way in SQL, so both show as tables/views; the
521
+ database's own error message usually settles which it was.
522
+
421
523
  ## Running the tests
422
524
 
423
525
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.9.1",
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",
@@ -42,6 +42,21 @@ export class Configuration {
42
42
  */
43
43
  captureSourceContext = true;
44
44
 
45
+ /**
46
+ * When an error comes from a database call (Sequelize, TypeORM, mysql2, and anything that wraps
47
+ * one), send the names of the stored procedure, table and view its SQL touched, so an issue says
48
+ * where to start looking. Names are identifiers, never values, which is why this defaults on.
49
+ * captureSqlStatement is the separate, opt-in step of also sending the statement itself, with
50
+ * every string and number replaced by "?"; off by default because even a masked statement
51
+ * describes your schema, and ForgeOps' own per-project setting is what durably governs whether
52
+ * the server stores it. See sqlStatement.js.
53
+ * @type {boolean}
54
+ */
55
+ captureSqlObjects = true;
56
+
57
+ /** @type {boolean} */
58
+ captureSqlStatement = false;
59
+
45
60
  /** @type {((message: string) => void) | null} */
46
61
  logger = null;
47
62
 
@@ -101,6 +116,26 @@ export class Configuration {
101
116
  * gems/forge_ops_tracker's own Configuration#trace_capture_threshold_ms default. */
102
117
  traceCaptureThresholdMs = 1000;
103
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
+
104
139
  /** @returns {string | null} */
105
140
  apiKey() {
106
141
  const parsed = this.#parsedDsn();
@@ -188,6 +223,51 @@ export class Configuration {
188
223
  return uri.replace(/\/events$/, "/spans");
189
224
  }
190
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
+
191
271
  /** @returns {boolean} */
192
272
  isEnabled() {
193
273
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { scrub, scrubString } from "./piiScrubber.js";
4
+ import * as sqlStatement from "./sqlStatement.js";
4
5
 
5
6
  const MAX_FRAMES = 500;
6
7
 
@@ -48,8 +49,14 @@ export class EventBuilder {
48
49
  * @param {Record<string, unknown>} [context]
49
50
  * @param {Record<string, unknown> | null} [user]
50
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.
51
58
  */
52
- build(error, context = {}, user = null, breadcrumbs = []) {
59
+ build(error, context = {}, user = null, breadcrumbs = [], request = {}) {
53
60
  const payload = {
54
61
  exception_class: error?.name ?? "Error",
55
62
  message: error?.message ?? "",
@@ -68,12 +75,24 @@ export class EventBuilder {
68
75
  if (breadcrumbs && breadcrumbs.length > 0) {
69
76
  payload.breadcrumbs = [...breadcrumbs];
70
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
+ }
87
+ this.#attachSql(payload, error);
71
88
 
72
89
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
73
90
  }
74
91
 
75
- // exception_class/occurred_at/environment/release/server_name/user are
76
- // left alone: structured fields this client or the host app sets
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
77
96
  // deliberately, not free text an exception or its context could
78
97
  // accidentally spill sensitive data into. user specifically is a
79
98
  // deliberate exemption, not an oversight: the scrubber's own email
@@ -94,9 +113,35 @@ export class EventBuilder {
94
113
  if ("breadcrumbs" in payload) {
95
114
  scrubbed.breadcrumbs = scrub(payload.breadcrumbs);
96
115
  }
116
+ if ("sql_statement" in payload) {
117
+ scrubbed.sql_statement = scrubString(payload.sql_statement);
118
+ }
97
119
  return scrubbed;
98
120
  }
99
121
 
122
+ // See sqlStatement.js for what's read off the error and how it's masked. The statement itself
123
+ // only goes out when captureSqlStatement is on; the extracted names go out on their own
124
+ // (captureSqlObjects) so an issue can still name the procedure or view involved.
125
+ #attachSql(payload, error) {
126
+ const config = this.#configuration;
127
+ if (!config.captureSqlObjects && !config.captureSqlStatement) {
128
+ return;
129
+ }
130
+
131
+ const masked = sqlStatement.mask(sqlStatement.findIn(error));
132
+ if (masked === null) {
133
+ return;
134
+ }
135
+
136
+ const found = sqlStatement.objects(masked);
137
+ if (found && config.captureSqlObjects) {
138
+ payload.sql_objects = found;
139
+ }
140
+ if (config.captureSqlStatement) {
141
+ payload.sql_statement = masked;
142
+ }
143
+ }
144
+
100
145
  #scrubFrame(frame) {
101
146
  const scrubbed = {
102
147
  ...frame,
@@ -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 one function actually used.
24
+ * just passing the functions actually used.
24
25
  *
25
- * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
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,10 +8,13 @@ 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 };
17
+ export { withSql } from "./sqlStatement.js";
15
18
 
16
19
  let configuration = null;
17
20
  let reporter = null;
@@ -77,6 +80,14 @@ const spanStorage = new AsyncLocalStorage();
77
80
  // SpanBuffer at all" as the real signal for "not inside a traced request").
78
81
  const spanParentStorage = new AsyncLocalStorage();
79
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
+
80
91
  function getConfiguration() {
81
92
  if (configuration === null) {
82
93
  configuration = new Configuration();
@@ -241,6 +252,64 @@ export function _runWithBreadcrumbTrail(callback) {
241
252
  return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
242
253
  }
243
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
+
244
313
  /**
245
314
  * Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
246
315
  * by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
@@ -260,7 +329,10 @@ export function _runWithSpanTrace(callback) {
260
329
  if (!config.trackTracing || !config.isEnabled()) {
261
330
  return callback();
262
331
  }
263
- const buffer = new SpanBuffer(config);
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 } : {});
264
336
  return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
265
337
  }
266
338
 
@@ -284,7 +356,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
284
356
  return;
285
357
  }
286
358
  buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
287
- if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs)) {
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)) {
288
364
  getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
289
365
  }
290
366
  }
@@ -302,15 +378,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
302
378
  * @param {Date} startedAt
303
379
  * @param {number} durationMs
304
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
305
383
  */
306
- export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
384
+ export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
307
385
  const config = getConfiguration();
308
386
  const buffer = spanStorage.getStore();
309
387
  if (!config.trackTracing || !buffer) {
310
388
  return;
311
389
  }
312
390
  const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
313
- buffer.record({ spanId: randomSpanId(), parentSpanId, name, kind, startedAt, durationMs, data });
391
+ buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
314
392
  }
315
393
 
316
394
  /**
@@ -362,7 +440,7 @@ export function init(options = {}) {
362
440
  // gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
363
441
  // config, checking configuration.trackTracing fresh on every actual outbound call instead (see
364
442
  // _recordSpan above), never at install time.
365
- installHttpTracing(_recordSpan);
443
+ installHttpTracing(_recordSpan, _traceparentFor);
366
444
 
367
445
  return config;
368
446
  }
@@ -372,13 +450,20 @@ export function init(options = {}) {
372
450
  * established for this async call chain, if anything; pass one explicitly to override that for
373
451
  * this one report.
374
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
+ *
375
457
  * @param {Error} error
376
458
  * @param {Record<string, unknown>} [context]
377
459
  * @param {Record<string, unknown> | null} [user]
378
460
  */
379
461
  export function captureException(error, context = {}, user = null) {
380
462
  const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
381
- getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs);
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);
382
467
  }
383
468
 
384
469
  /**
@@ -564,6 +649,7 @@ export function _resetForTesting() {
564
649
  // sharing the same root context. disable() drops it and leaves the instance perfectly usable
565
650
  // again for the next run()/enterWith() call, confirmed directly, not assumed.
566
651
  breadcrumbStorage.disable();
652
+ requestStorage.disable();
567
653
  // Re-installed fresh on the very next init() call in whatever test runs next: without this,
568
654
  // http.request/https.request would stay wrapped by a closure over a *previous* test's own
569
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
- captureException(err, {
24
- path: req.path,
25
- method: req.method,
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
- captureException(error, {
23
- path: request.url,
24
- method: request.method,
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
- _runWithSpanTrace(() => {
26
- const startedAt = new Date();
27
- const start = performance.now();
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
- res.on("finish", () => {
30
- const durationMs = performance.now() - start;
31
- const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
32
- _finishSpanTrace(transactionName, startedAt, durationMs);
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
- next();
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
- _runWithSpanTrace(() => {
63
- request._forgeOpsSpanStartedAt = new Date();
64
- request._forgeOpsSpanStart = performance.now();
65
- done();
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 { randomBytes } from "node:crypto";
1
+ import { generateSpanId, generateTraceId } from "./traceParent.js";
2
2
 
3
3
  /**
4
- * A short, unique-enough identifier for one span: 8 random bytes as hex, matching
5
- * gems/forge_ops_tracker/lib/forge_ops_tracker/span_buffer.rb's own SecureRandom.hex(8). The
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 randomBytes(8).toString("hex");
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
- constructor(configuration) {
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 = randomBytes(16).toString("hex");
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 explicitly, the same reasoning span_buffer.rb's own #record documents for
46
- * why that can't just be read off of "whatever's currently open" for the root specifically.
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 ? null : parentSpanId,
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,159 @@
1
+ // Finds the SQL behind a database error and reduces it to something safe to send: the names of
2
+ // the stored procedures, tables and views it touched, and (only if configuration.
3
+ // captureSqlStatement is on) the statement itself with every string and number replaced by "?".
4
+ // Ported from gems/forge_ops_tracker's SqlStatement, which is itself ported from the server's own
5
+ // SqlStatementMasker/SqlObjectExtractor: same rules everywhere, and the server applies them again
6
+ // on arrival, so a difference here can only ever mean less is masked client-side, never that
7
+ // something unmasked gets stored.
8
+ //
9
+ // Deliberately a single pass over a few patterns, not a SQL parser. No regex lookbehind either:
10
+ // the same source is shared with the browser and React Native SDKs, where an older engine would
11
+ // fail to even parse one, so the "not part of an identifier" check on numbers captures the
12
+ // preceding character and puts it back instead.
13
+
14
+ const MASK = "?";
15
+ const MAX_LENGTH = 4000;
16
+ const MAX_NAMES = 10;
17
+ const MAX_NAME_LENGTH = 200;
18
+ const MAX_CAUSE_DEPTH = 5;
19
+
20
+ // 1: string literal (or one cut off by truncation) 2: dollar-quote tag 3: char before a number
21
+ // 4: the number, not part of an identifier or a $1 placeholder
22
+ const LITERAL = /'(?:[^']|'')*(?:'|$)|(\$[A-Za-z_]*\$)[\s\S]*?(?:\1|$)|(^|[^\w$.])(\d+(?:\.\d+)?)(?!\w)/g;
23
+
24
+ const PART = '(?:[\\w$#@]+|"[^"]+"|\\[[^\\]]+\\]|`[^`]+`)';
25
+ const NAME = `${PART}(?:\\.${PART})*`;
26
+ const OPERATIONS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "WITH", "CALL", "EXEC", "EXECUTE", "CREATE", "ALTER", "DROP", "TRUNCATE"]);
27
+ const PROCEDURE_CALL = new RegExp(`\\b(?:CALL|EXEC(?:UTE)?|PERFORM)\\s+(?!IMMEDIATE\\b|FUNCTION\\b|PROCEDURE\\b)(${NAME})`, "gi");
28
+ const RELATION = new RegExp(`\\b(FROM|JOIN|INTO|UPDATE|TABLE)\\s+(${NAME})(\\s*\\()?`, "gi");
29
+ const SELECT_FUNCTION = new RegExp(`^\\s*SELECT\\s+(${NAME})\\s*\\(`, "i");
30
+ const BUILTINS = new Set([
31
+ "count", "sum", "min", "max", "avg", "now", "coalesce", "nullif", "lower", "upper", "length", "concat",
32
+ "cast", "date_trunc", "current_timestamp", "current_date", "row_number", "rank", "json_build_object",
33
+ "json_agg", "array_agg",
34
+ ]);
35
+ const FROM_INSIDE_FUNCTION = /\b(?:EXTRACT|SUBSTRING|TRIM|OVERLAY)\s*\([^()]*\)/gi;
36
+ const KEYWORDS_NOT_NAMES = new Set(["select", "set", "values", "where", "lateral", "only", "unnest", "generate_series"]);
37
+ const FULL_NAME = new RegExp(`^${NAME}$`);
38
+ const FROM_WORD = /\bFROM\b/i;
39
+
40
+ // Properties that carry the statement on the errors Node database libraries raise: Sequelize and
41
+ // mysql2 use .sql (Sequelize also nests the driver's own error under .parent/.original), TypeORM's
42
+ // QueryFailedError uses .query. Only a string counts, never an object that happens to share the
43
+ // name (a Sequelize query builder, say).
44
+ const STATEMENT_PROPERTIES = ["sql", "query"];
45
+
46
+ /**
47
+ * The raw statement off the error itself or, for an app that wraps a database error in its own
48
+ * exception, off whatever it was raised from (.cause, or Sequelize's .parent/.original).
49
+ * @param {unknown} error
50
+ * @returns {string | null}
51
+ */
52
+ export function findIn(error) {
53
+ const seen = new Set();
54
+ const queue = [error];
55
+ let depth = 0;
56
+ while (queue.length > 0 && depth < MAX_CAUSE_DEPTH * 3) {
57
+ const current = queue.shift();
58
+ depth += 1;
59
+ if (!current || typeof current !== "object" || seen.has(current)) {
60
+ continue;
61
+ }
62
+ seen.add(current);
63
+ for (const property of STATEMENT_PROPERTIES) {
64
+ const value = current[property];
65
+ if (typeof value === "string" && value.trim() !== "") {
66
+ return value;
67
+ }
68
+ }
69
+ queue.push(current.cause, current.parent, current.original);
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * @param {string | null | undefined} statement
76
+ * @returns {string | null}
77
+ */
78
+ export function mask(statement) {
79
+ if (typeof statement !== "string" || statement.trim() === "") {
80
+ return null;
81
+ }
82
+ const masked = statement.replace(LITERAL, (whole, _tag, before, number) => (number === undefined ? MASK : `${before}${MASK}`));
83
+ return masked.length > MAX_LENGTH ? `${masked.slice(0, MAX_LENGTH)}...` : masked;
84
+ }
85
+
86
+ /**
87
+ * Takes an already-masked statement (so a keyword inside a string value can't be mistaken for
88
+ * SQL). Returns null when nothing recognizable was found.
89
+ * @param {string | null} masked
90
+ * @returns {{ operation?: string, procedures: string[], relations: string[] } | null}
91
+ */
92
+ export function objects(masked) {
93
+ if (typeof masked !== "string" || masked.trim() === "") {
94
+ return null;
95
+ }
96
+
97
+ const sql = masked.replace(FROM_INSIDE_FUNCTION, " ");
98
+ const procedures = [...sql.matchAll(PROCEDURE_CALL)].map((m) => m[1]);
99
+ const relations = [];
100
+
101
+ for (const [, keyword, name, paren] of sql.matchAll(RELATION)) {
102
+ if (KEYWORDS_NOT_NAMES.has(name.toLowerCase())) {
103
+ continue;
104
+ }
105
+ const functionCall = Boolean(paren) && ["FROM", "JOIN"].includes(keyword.toUpperCase());
106
+ (functionCall ? procedures : relations).push(name);
107
+ }
108
+
109
+ const fn = SELECT_FUNCTION.exec(sql)?.[1];
110
+ if (fn && !BUILTINS.has(fn.toLowerCase()) && !FROM_WORD.test(sql)) {
111
+ procedures.push(fn);
112
+ }
113
+
114
+ const operation = (/^\s*(\w+)/.exec(sql)?.[1] ?? "").toUpperCase();
115
+ const result = {};
116
+ if (OPERATIONS.has(operation)) {
117
+ result.operation = operation;
118
+ }
119
+ result.procedures = clean(procedures);
120
+ result.relations = clean(relations);
121
+ if (result.procedures.length === 0 && result.relations.length === 0 && !("operation" in result)) {
122
+ return null;
123
+ }
124
+ return result;
125
+ }
126
+
127
+ function clean(names) {
128
+ const cleaned = [];
129
+ for (const raw of names) {
130
+ const name = raw.trim().slice(0, MAX_NAME_LENGTH);
131
+ if (FULL_NAME.test(name) && !cleaned.includes(name)) {
132
+ cleaned.push(name);
133
+ }
134
+ }
135
+ return cleaned.slice(0, MAX_NAMES);
136
+ }
137
+
138
+ /**
139
+ * Attaches the SQL statement that caused `error` to it and returns the same error, so the code
140
+ * that ran the query can hand it over where the reporter finds it with no extra call. Use it when
141
+ * the database library's own errors don't carry the statement (node-postgres, better-sqlite3 and
142
+ * Prisma don't):
143
+ *
144
+ * try { await pool.query(sql, params); }
145
+ * catch (error) { throw withSql(error, sql); }
146
+ *
147
+ * Only the names of the stored procedure, table and view are sent by default, and the statement
148
+ * itself only with `captureSqlStatement` on, with every string and number replaced by "?" first;
149
+ * the raw statement never leaves the process. Non-enumerable, so it doesn't show up when the error
150
+ * is logged or serialized.
151
+ * @template {object} E
152
+ * @param {E} error
153
+ * @param {string} statement
154
+ * @returns {E}
155
+ */
156
+ export function withSql(error, statement) {
157
+ Object.defineProperty(error, "sql", { value: statement, enumerable: false, configurable: true, writable: true });
158
+ return error;
159
+ }
@@ -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
+ }