@forge-ops/tracker 0.10.0 → 0.12.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
@@ -353,9 +375,59 @@ flush and would otherwise grow it for as long as the process lives. A NaN or inf
353
375
  dropped at capture: `JSON.stringify` turns it into `null` and the server would reject the whole
354
376
  batch behind it. Requires a ForgeOps plan that includes custom metrics / infrastructure monitoring.
355
377
 
378
+ ## What changed
379
+
380
+ ForgeOps can show what changed in your system next to the errors and slowdowns that followed it.
381
+ Two ways in:
382
+
383
+ **Record a change yourself** when something changes that no deploy captures, like a feature flag
384
+ flipped, a config value edited, or a migration run by hand:
385
+
386
+ ```js
387
+ import * as forgeOpsTracker from "@forge-ops/tracker";
388
+
389
+ forgeOpsTracker.recordChange({
390
+ kind: "feature_flag", // feature_flag, config, migration, dependency, infrastructure, or other
391
+ title: "Enabled new_checkout for 10% of users",
392
+ details: { flag: "new_checkout", rolloutPercent: 10 },
393
+ actor: "ops@example.com",
394
+ url: "https://flags.example.com/new_checkout",
395
+ });
396
+ ```
397
+
398
+ `kind` and `title` are required; `details`, `environment` (defaults to the configured one),
399
+ `service`, `actor`, `url`, `id` (an idempotency key, so sending the same change twice records it
400
+ once), and `occurredAt` (a `Date` or ISO 8601 string, defaulting to now) are optional. An unknown
401
+ `kind` is sent as `other`. It's queued and delivered on the same async loop as error events, so it
402
+ never slows down the caller, never throws, and is a no-op when the client isn't enabled for the
403
+ environment.
404
+
405
+ **Changes between deploys are detected for you.** Once per process, `init()` schedules a snapshot
406
+ of what the process is running: the Node version, and each of the `dependencies` in your app's
407
+ `package.json` (read from `appRoot`, the working directory by default) resolved to the version
408
+ actually installed in `node_modules`. ForgeOps compares it with the previous boot's and records
409
+ whatever changed, such as a package upgrade. It's sent on a later event-loop turn, so startup never
410
+ waits on it.
411
+
412
+ ```js
413
+ forgeOpsTracker.init({
414
+ dsn: "https://<api_key>@getforgeops.net/api/v1/events",
415
+ detectChanges: true, // default; false sends no startup snapshot
416
+ trackEnvVarNames: false, // default; true also sends environment variable names
417
+ });
418
+ ```
419
+
420
+ With `trackEnvVarNames` on, the snapshot lists the names of your environment variables (never their
421
+ values), so an added or removed variable shows up as a change. Names that differ from host to host,
422
+ like `HOSTNAME`, `PATH`, `PORT`, `LC_*`, and Kubernetes service variables, are left out, as are the
423
+ SDK's own `FORGE_OPS_*` settings.
424
+
425
+ Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are rejected
426
+ server-side and dropped, exactly like any other delivery failure.
427
+
356
428
  ## Distributed tracing
357
429
 
358
- For one slow request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
430
+ For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
359
431
  `registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
360
432
  call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under
361
433
  it, so ForgeOps can render a waterfall for that one request.
@@ -363,7 +435,7 @@ it, so ForgeOps can render a waterfall for that one request.
363
435
  ```js
364
436
  import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
365
437
 
366
- app.use(forgeOpsTrackerTracingExpressMiddleware);
438
+ app.use(forgeOpsTrackerTracingExpressMiddleware); // before your routes
367
439
  ```
368
440
 
369
441
  ```js
@@ -375,7 +447,9 @@ registerForgeOpsTrackerTracing(fastify);
375
447
  This is the whole point of the feature, so it's worth being explicit about: a request's own trace
376
448
  is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided
377
449
  entirely client-side before a single byte goes over the wire. A normal, fast request costs
378
- nothing extra.
450
+ nothing extra. A request that errored (an unhandled error, or any `captureException()` during it)
451
+ is the one exception: its trace is always sent, however fast it was, since the spans leading up to
452
+ an error are exactly what you want next to it.
379
453
 
380
454
  ```js
381
455
  forgeOpsTracker.init({
@@ -413,6 +487,58 @@ with `trackTracing` off: it just runs the callback and records nothing, never th
413
487
  Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured
414
488
  trace is simply rejected server-side and dropped, exactly like any other delivery failure.
415
489
 
490
+ ### Following a request across services
491
+
492
+ Traces use the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard, so a request can
493
+ be followed from one service into the next, whichever language or tracing library the other
494
+ service uses:
495
+
496
+ - **Incoming**: a request that arrives with a valid `traceparent` header continues that trace (same
497
+ trace id), and its root span records the caller's span as its parent. A missing or malformed
498
+ header just starts a new trace.
499
+ - **Outgoing**: every outbound call made through Node's `http`/`https` modules during a request
500
+ gets a `traceparent` header whose parent id is that call's own span, so the next service's spans
501
+ nest under it. A `traceparent` you set yourself is never replaced, and this client's own
502
+ deliveries to ForgeOps never get one. The global `fetch()` doesn't go through `http`/`https`, so
503
+ it isn't instrumented and gets no header.
504
+
505
+ A trace id exists for every request even with `trackTracing` off, and the header is still sent,
506
+ since the trace id is also what links an error here to an error in the service you called. Seeing
507
+ the two errors connected in ForgeOps needs both services' projects linked there.
508
+
509
+ ```js
510
+ import http from "node:http";
511
+ import express from "express";
512
+ import * as forgeOpsTracker from "@forge-ops/tracker";
513
+ import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
514
+ import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
515
+
516
+ forgeOpsTracker.init({
517
+ dsn: "https://<api_key>@getforgeops.net/api/v1/events",
518
+ propagateTraces: true, // default true; false never sends traceparent
519
+ // Default null: every host. A string matches that host and its subdomains on a dot boundary
520
+ // ("internal.example" matches "orders.internal.example", not "notinternal.example"); a RegExp is
521
+ // tested against the host, without the port.
522
+ tracePropagationTargets: ["internal.example", /^10\.0\./],
523
+ });
524
+
525
+ const app = express();
526
+ app.use(forgeOpsTrackerTracingExpressMiddleware);
527
+
528
+ app.post("/checkout", (req, res, next) => {
529
+ // Carries traceparent: 00-<this request's trace id>-<this call's span id>-01
530
+ const call = http.request("http://orders.internal.example/orders", { method: "POST" }, (response) => {
531
+ response.resume();
532
+ res.status(response.statusCode === 201 ? 201 : 502).end();
533
+ });
534
+ call.on("error", next);
535
+ call.end(JSON.stringify({ sku: "A1" }));
536
+ });
537
+
538
+ app.use(forgeOpsTrackerExpressMiddleware);
539
+ app.listen(3000);
540
+ ```
541
+
416
542
  **Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
417
543
  of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
418
544
  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.10.0",
3
+ "version": "0.12.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/changes.js ADDED
@@ -0,0 +1,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Payloads for "what changed": one explicit recordChange() call (POST /api/v1/changes), and the
6
+ * startup snapshot (POST /api/v1/change_snapshots) that ForgeOps diffs against the previous boot's
7
+ * to record what changed between deploys. Mirrors gems/forge_ops_tracker's Change and
8
+ * ChangeSnapshot.
9
+ *
10
+ * The snapshot leaves out any key it can't determine reliably rather than guessing, since the
11
+ * server reads a missing key as "unknown", never as "everything was removed". Environment variable
12
+ * names are only ever names, never values, and only when trackEnvVarNames is on.
13
+ */
14
+
15
+ /** The server rejects any other kind outright (422), so an unknown one is sent as "other" rather
16
+ * than dropped: the change still gets recorded, just less specifically categorized. */
17
+ export const CHANGE_KINDS = Object.freeze(["feature_flag", "config", "migration", "dependency", "infrastructure", "other"]);
18
+ const MAX_TITLE_LENGTH = 200;
19
+
20
+ const MAX_DEPENDENCIES = 3000;
21
+ const MAX_NAME_LENGTH = 200;
22
+ const MAX_VERSION_LENGTH = 100;
23
+ const MAX_ENV_VAR_NAMES = 3000;
24
+
25
+ /** Host-specific variables that differ from one box to the next (or one boot to the next) without
26
+ * anything about the deploy having changed, so a fleet doesn't look like it's changing on every
27
+ * restart. The SDK's own FORGE_OPS_* settings are dropped too. */
28
+ const ENV_VAR_DENYLIST = new Set([
29
+ "HOSTNAME", "HOST", "HOME", "PATH", "PWD", "OLDPWD", "SHLVL", "_", "TERM", "USER", "LOGNAME", "SHELL",
30
+ "LANG", "TMPDIR", "TZ", "PORT", "DYNO", "INVOCATION_ID", "JOURNAL_STREAM",
31
+ ]);
32
+ const ENV_VAR_DENYLIST_PATTERNS = [
33
+ /^LC_/, /^SYSTEMD_/, /^MEMORY_PRESSURE_/, /^KUBERNETES_/, /^FORGE_OPS_/,
34
+ /_SERVICE_HOST$/, /_SERVICE_PORT/, /_PORT_.*_TCP/,
35
+ ];
36
+
37
+ /** @param {unknown} kind */
38
+ export function normalizeKind(kind) {
39
+ return CHANGE_KINDS.includes(kind) ? kind : "other";
40
+ }
41
+
42
+ /**
43
+ * The body for one recordChange() call, or null when there's nothing sendable (a blank title: the
44
+ * server requires one, so posting it anyway would only ever come back 422).
45
+ * @param {import("./configuration.js").Configuration} configuration
46
+ * @param {{ kind: string, title: string, details?: Record<string, unknown>, environment?: string,
47
+ * service?: string, actor?: string, url?: string, id?: string, occurredAt?: Date | string }} change
48
+ */
49
+ export function buildChange(configuration, { kind, title, details, environment, service, actor, url, id, occurredAt } = {}) {
50
+ const trimmed = title == null ? "" : String(title).trim();
51
+ if (trimmed === "") {
52
+ return null;
53
+ }
54
+
55
+ const payload = {
56
+ kind: normalizeKind(kind),
57
+ title: trimmed.slice(0, MAX_TITLE_LENGTH),
58
+ details: details !== null && typeof details === "object" && !Array.isArray(details) ? details : {},
59
+ environment: String(environment ?? configuration.environment),
60
+ };
61
+ for (const [key, value] of Object.entries({ service, actor, url, id })) {
62
+ if (value != null) {
63
+ payload[key] = String(value);
64
+ }
65
+ }
66
+ payload.occurred_at = iso8601(occurredAt);
67
+ return payload;
68
+ }
69
+
70
+ /** @param {Date | string | undefined} value */
71
+ function iso8601(value) {
72
+ if (typeof value === "string") {
73
+ return value;
74
+ }
75
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
76
+ return value.toISOString();
77
+ }
78
+ return new Date().toISOString();
79
+ }
80
+
81
+ /** @param {string} name */
82
+ export function isDeniedEnvVar(name) {
83
+ return ENV_VAR_DENYLIST.has(name) || ENV_VAR_DENYLIST_PATTERNS.some((pattern) => pattern.test(name));
84
+ }
85
+
86
+ /**
87
+ * Names only, never values, sorted so the same set always serializes the same way.
88
+ * @param {Record<string, string | undefined>} [env]
89
+ */
90
+ export function envVarNames(env = process.env) {
91
+ return Object.keys(env)
92
+ .filter((name) => !isDeniedEnvVar(name))
93
+ .sort()
94
+ .slice(0, MAX_ENV_VAR_NAMES);
95
+ }
96
+
97
+ export function runtime() {
98
+ return `node ${process.versions.node}`;
99
+ }
100
+
101
+ /**
102
+ * The app's own direct dependencies (the "dependencies" in `<appRoot>/package.json`), each
103
+ * resolved to the version actually installed under `<appRoot>/node_modules/<name>/package.json`,
104
+ * never the semver range the manifest asks for. A dependency that isn't installed there is left
105
+ * out rather than guessed at, and so is the whole key (null) when there's no package.json to read.
106
+ * @param {string} appRoot
107
+ */
108
+ export async function dependencies(appRoot) {
109
+ let manifest;
110
+ try {
111
+ manifest = JSON.parse(await readFile(path.join(appRoot, "package.json"), "utf8"));
112
+ } catch {
113
+ return null;
114
+ }
115
+
116
+ const names = Object.keys(manifest?.dependencies ?? {}).sort().slice(0, MAX_DEPENDENCIES);
117
+ const resolved = await Promise.all(names.map((name) => installedVersion(appRoot, name)));
118
+ const found = {};
119
+ names.forEach((name, index) => {
120
+ if (resolved[index]) {
121
+ found[name.slice(0, MAX_NAME_LENGTH)] = resolved[index].slice(0, MAX_VERSION_LENGTH);
122
+ }
123
+ });
124
+ return Object.keys(found).length > 0 ? found : null;
125
+ }
126
+
127
+ async function installedVersion(appRoot, name) {
128
+ try {
129
+ const installed = JSON.parse(await readFile(path.join(appRoot, "node_modules", name, "package.json"), "utf8"));
130
+ return typeof installed?.version === "string" ? installed.version : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** @param {import("./configuration.js").Configuration} configuration */
137
+ export async function buildSnapshot(configuration) {
138
+ const state = { runtime: runtime() };
139
+ const deps = await dependencies(configuration.appRoot);
140
+ if (deps) {
141
+ state.dependencies = deps;
142
+ }
143
+ if (configuration.trackEnvVarNames) {
144
+ state.env_var_names = envVarNames();
145
+ }
146
+ return { environment: configuration.environment, state };
147
+ }
package/src/client.js CHANGED
@@ -68,6 +68,23 @@ export class Client {
68
68
  return this.#post(this.#configuration.infrastructureMetricsUri(), { metrics });
69
69
  }
70
70
 
71
+ /**
72
+ * One recordChange() call; see Api::V1::ChangesController. A plan without change tracking
73
+ * answers 403, which is just a false here like any other non-2xx.
74
+ * @param {Record<string, unknown>} payload
75
+ */
76
+ async deliverChange(payload) {
77
+ return this.#post(this.#configuration.changesUri(), payload);
78
+ }
79
+
80
+ /**
81
+ * The startup snapshot; see Api::V1::ChangeSnapshotsController.
82
+ * @param {Record<string, unknown>} payload
83
+ */
84
+ async deliverChangeSnapshot(payload) {
85
+ return this.#post(this.#configuration.changeSnapshotsUri(), payload);
86
+ }
87
+
71
88
  /**
72
89
  * @param {string | null} uri
73
90
  * @param {Record<string, unknown>} payload
@@ -116,6 +116,41 @@ 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
+
139
+ /**
140
+ * Sends one startup snapshot per process (the Node version and the installed versions of your
141
+ * package.json dependencies; see changes.js) which ForgeOps diffs against the previous boot's to
142
+ * record what changed between deploys. On by default, same posture as every other automatic
143
+ * behavior here.
144
+ */
145
+ detectChanges = true;
146
+ /**
147
+ * Whether that snapshot also lists the names of this process's environment variables, so an
148
+ * added or removed variable shows up as a change. Off by default: names only, never values, but
149
+ * even names can say more about an app than some teams want to share. Host-specific names
150
+ * (HOSTNAME, PATH, PORT, and so on; see changes.js) are always left out.
151
+ */
152
+ trackEnvVarNames = false;
153
+
119
154
  /** @returns {string | null} */
120
155
  apiKey() {
121
156
  const parsed = this.#parsedDsn();
@@ -203,6 +238,76 @@ export class Configuration {
203
238
  return uri.replace(/\/events$/, "/spans");
204
239
  }
205
240
 
241
+ /**
242
+ * Same derivation again, swapping the trailing /events for /changes (recordChange()).
243
+ * @returns {string | null}
244
+ */
245
+ changesUri() {
246
+ const uri = this.ingestionUri();
247
+ if (!uri) {
248
+ return null;
249
+ }
250
+ return uri.replace(/\/events$/, "/changes");
251
+ }
252
+
253
+ /**
254
+ * Same derivation again, swapping the trailing /events for /change_snapshots (the startup
255
+ * snapshot).
256
+ * @returns {string | null}
257
+ */
258
+ changeSnapshotsUri() {
259
+ const uri = this.ingestionUri();
260
+ if (!uri) {
261
+ return null;
262
+ }
263
+ return uri.replace(/\/events$/, "/change_snapshots");
264
+ }
265
+
266
+ /**
267
+ * Whether an outbound request to `host` should carry a traceparent header; see
268
+ * propagateTraces/tracePropagationTargets above. Case-insensitive, since hostnames are.
269
+ * @param {string} host
270
+ * @returns {boolean}
271
+ */
272
+ propagatesTraceTo(host) {
273
+ if (!this.propagateTraces) {
274
+ return false;
275
+ }
276
+ if (this.tracePropagationTargets === null || this.tracePropagationTargets === undefined) {
277
+ return true;
278
+ }
279
+ const normalized = String(host ?? "").toLowerCase();
280
+ return this.tracePropagationTargets.some((target) => {
281
+ if (target instanceof RegExp) {
282
+ target.lastIndex = 0; // a /g or /y pattern would otherwise carry state from the last test
283
+ return target.test(normalized);
284
+ }
285
+ const suffix = String(target).toLowerCase().replace(/^\./, "");
286
+ return suffix !== "" && (normalized === suffix || normalized.endsWith(`.${suffix}`));
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Whether a request to `host`:`port` is one of this client's own deliveries to ForgeOps (same
292
+ * host and port as the DSN), so the outbound-HTTP wrapper never adds a traceparent to it. The
293
+ * Client itself uses fetch(), which that wrapper never sees, so this is a second guard, not the
294
+ * only one.
295
+ * @param {string} host
296
+ * @param {number | string | null | undefined} port
297
+ * @param {string} protocol "http:" or "https:"
298
+ * @returns {boolean}
299
+ */
300
+ isOwnHost(host, port, protocol) {
301
+ const parsed = this.#parsedDsn();
302
+ if (parsed === null || !host) {
303
+ return false;
304
+ }
305
+ const defaultPort = (scheme) => (scheme === "http:" ? "80" : scheme === "https:" ? "443" : "");
306
+ const ownPort = parsed.port || defaultPort(parsed.protocol);
307
+ const targetPort = port === null || port === undefined || port === "" ? defaultPort(protocol) : String(port);
308
+ return String(host).toLowerCase() === parsed.hostname.toLowerCase() && targetPort === ownPort;
309
+ }
310
+
206
311
  /** @returns {boolean} */
207
312
  isEnabled() {
208
313
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -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 are
78
- // 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
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
@@ -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
  }