@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 +129 -3
- package/package.json +1 -1
- package/src/changes.js +147 -0
- package/src/client.js +17 -0
- package/src/configuration.js +105 -0
- package/src/eventBuilder.js +20 -3
- package/src/httpTracing.js +53 -10
- package/src/index.js +169 -8
- package/src/integrations/express.js +16 -5
- package/src/integrations/fastify.js +18 -5
- package/src/integrations/tracing.js +73 -16
- package/src/reporter.js +3 -2
- package/src/requestState.js +117 -0
- package/src/spanBuffer.js +32 -9
- package/src/traceParent.js +88 -0
package/README.md
CHANGED
|
@@ -113,6 +113,28 @@ themselves, long before it would ever reach here.
|
|
|
113
113
|
Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
|
|
114
114
|
dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
|
|
115
115
|
|
|
116
|
+
## Where an error happened
|
|
117
|
+
|
|
118
|
+
An error reported during a request carries three extra fields:
|
|
119
|
+
|
|
120
|
+
- `transaction_name`: the method and the route pattern, `"GET /orders/:id"`, the same name
|
|
121
|
+
performance monitoring and traces use.
|
|
122
|
+
- `endpoint`: the method and the route as declared, `"GET /orders/:id"`. Never the literal path, so
|
|
123
|
+
an id or a token in the URL never ends up here. On Express, it's the route as declared on the
|
|
124
|
+
router that matched it: for a router mounted with `app.use("/api", router)`, that's the part
|
|
125
|
+
declared on the router, without `/api`, since the mount path Express exposes is the literal
|
|
126
|
+
matched one.
|
|
127
|
+
- `trace_id`: the request's W3C trace id, which ForgeOps uses to link this error to errors that
|
|
128
|
+
other services reported for the same trace (see "Following a request across services" below).
|
|
129
|
+
|
|
130
|
+
Register `forgeOpsTrackerTracingExpressMiddleware` before your routes, or call
|
|
131
|
+
`registerForgeOpsTrackerTracing(app)` on Fastify (see "Distributed tracing" below), for these to be
|
|
132
|
+
set on an error you capture yourself inside a handler; Fastify knows the route before any handler
|
|
133
|
+
runs, Express as soon as the matched handler starts. `forgeOpsTrackerExpressMiddleware` and
|
|
134
|
+
`registerForgeOpsTracker` add them to an unhandled error on their own. They're left out entirely
|
|
135
|
+
outside a request (a script, a worker), and they're never PII-scrubbed, like `environment` and
|
|
136
|
+
`release`: they're structured fields, not free text.
|
|
137
|
+
|
|
116
138
|
## Identifying users
|
|
117
139
|
|
|
118
140
|
```js
|
|
@@ -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.
|
|
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
|
package/src/configuration.js
CHANGED
|
@@ -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);
|
package/src/eventBuilder.js
CHANGED
|
@@ -49,8 +49,14 @@ export class EventBuilder {
|
|
|
49
49
|
* @param {Record<string, unknown>} [context]
|
|
50
50
|
* @param {Record<string, unknown> | null} [user]
|
|
51
51
|
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
52
|
+
* @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request]
|
|
53
|
+
* Where the error happened (see RequestState in requestState.js): transactionName is the same
|
|
54
|
+
* "GET /orders/:id" name the request's root span and performance samples use, endpoint the
|
|
55
|
+
* human-readable route ("GET /orders/:id"), traceId the request's 32-character lowercase hex
|
|
56
|
+
* W3C trace id, which is also what links this error to errors other services reported for the
|
|
57
|
+
* same trace. All absent outside a request, and a missing one is left out of the payload.
|
|
52
58
|
*/
|
|
53
|
-
build(error, context = {}, user = null, breadcrumbs = []) {
|
|
59
|
+
build(error, context = {}, user = null, breadcrumbs = [], request = {}) {
|
|
54
60
|
const payload = {
|
|
55
61
|
exception_class: error?.name ?? "Error",
|
|
56
62
|
message: error?.message ?? "",
|
|
@@ -69,13 +75,24 @@ export class EventBuilder {
|
|
|
69
75
|
if (breadcrumbs && breadcrumbs.length > 0) {
|
|
70
76
|
payload.breadcrumbs = [...breadcrumbs];
|
|
71
77
|
}
|
|
78
|
+
if (request.transactionName) {
|
|
79
|
+
payload.transaction_name = request.transactionName;
|
|
80
|
+
}
|
|
81
|
+
if (request.endpoint) {
|
|
82
|
+
payload.endpoint = request.endpoint;
|
|
83
|
+
}
|
|
84
|
+
if (request.traceId) {
|
|
85
|
+
payload.trace_id = request.traceId;
|
|
86
|
+
}
|
|
72
87
|
this.#attachSql(payload, error);
|
|
73
88
|
|
|
74
89
|
return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
// exception_class/occurred_at/environment/release/server_name/user
|
|
78
|
-
// left alone
|
|
92
|
+
// exception_class/occurred_at/environment/release/server_name/user/
|
|
93
|
+
// transaction_name/endpoint/trace_id are left alone (endpoint is a
|
|
94
|
+
// declared route pattern, never the literal path, so there's no id or
|
|
95
|
+
// token in it to scrub): structured fields this client or the host app sets
|
|
79
96
|
// deliberately, not free text an exception or its context could
|
|
80
97
|
// accidentally spill sensitive data into. user specifically is a
|
|
81
98
|
// deliberate exemption, not an oversight: the scrubber's own email
|
package/src/httpTracing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
+
import { generateSpanId, TRACEPARENT_HEADER } from "./traceParent.js";
|
|
3
4
|
|
|
4
5
|
let installed = false;
|
|
5
6
|
let originalHttpRequest = null;
|
|
@@ -20,19 +21,28 @@ let originalHttpsRequest = null;
|
|
|
20
21
|
* recordSpan is passed in as a plain parameter rather than imported directly from index.js: this
|
|
21
22
|
* module gets installed FROM index.js's own init(), so importing index.js back from here would
|
|
22
23
|
* create a circular module dependency index.js has never needed before, for no real benefit over
|
|
23
|
-
* just passing the
|
|
24
|
+
* just passing the functions actually used.
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
+
* Also propagates the current request's trace to the service being called, as a W3C `traceparent`
|
|
27
|
+
* header (see traceParent.js, and Configuration#propagateTraces/tracePropagationTargets for turning
|
|
28
|
+
* it off or narrowing it to specific hosts). The header's parent id is this outbound call's own
|
|
29
|
+
* span id, generated before the call is made (the span itself can only be recorded afterward, once
|
|
30
|
+
* its duration is known) and then recorded with that exact id, so the downstream service's root
|
|
31
|
+
* span points at a span that really exists in this trace. traceparentFor decides whether there is
|
|
32
|
+
* a trace to continue at all (only inside a request) and returns null otherwise.
|
|
33
|
+
*
|
|
34
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>, spanId?: string) => void} recordSpan
|
|
35
|
+
* @param {(host: string, port: number | string | null | undefined, protocol: string, spanId: string) => string | null} [traceparentFor]
|
|
26
36
|
*/
|
|
27
|
-
export function installHttpTracing(recordSpan) {
|
|
37
|
+
export function installHttpTracing(recordSpan, traceparentFor = () => null) {
|
|
28
38
|
if (installed) {
|
|
29
39
|
return;
|
|
30
40
|
}
|
|
31
41
|
installed = true;
|
|
32
42
|
originalHttpRequest = http.request;
|
|
33
43
|
originalHttpsRequest = https.request;
|
|
34
|
-
http.request = wrap(originalHttpRequest, recordSpan);
|
|
35
|
-
https.request = wrap(originalHttpsRequest, recordSpan);
|
|
44
|
+
http.request = wrap(originalHttpRequest, recordSpan, traceparentFor, "http:");
|
|
45
|
+
https.request = wrap(originalHttpsRequest, recordSpan, traceparentFor, "https:");
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** @internal test-only: restores http/https.request exactly as installHttpTracing found them. */
|
|
@@ -47,12 +57,14 @@ export function _uninstallHttpTracing() {
|
|
|
47
57
|
originalHttpsRequest = null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
function wrap(originalRequest, recordSpan) {
|
|
60
|
+
function wrap(originalRequest, recordSpan, traceparentFor, defaultProtocol) {
|
|
51
61
|
return function patchedRequest(...args) {
|
|
62
|
+
const spanId = generateSpanId();
|
|
52
63
|
const startedAt = new Date();
|
|
53
64
|
const start = performance.now();
|
|
54
65
|
const req = originalRequest.apply(this, args);
|
|
55
|
-
const { method, host } = describeRequest(args);
|
|
66
|
+
const { method, host, port, protocol } = describeRequest(args, defaultProtocol);
|
|
67
|
+
propagateTrace(req, () => traceparentFor(host, port, protocol, spanId));
|
|
56
68
|
|
|
57
69
|
// "response" and "error" are mutually exclusive on a real ClientRequest, but guarded anyway:
|
|
58
70
|
// recording twice for the same outbound call would double it up in the waterfall.
|
|
@@ -62,7 +74,7 @@ function wrap(originalRequest, recordSpan) {
|
|
|
62
74
|
return;
|
|
63
75
|
}
|
|
64
76
|
finished = true;
|
|
65
|
-
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status });
|
|
77
|
+
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status }, spanId);
|
|
66
78
|
};
|
|
67
79
|
|
|
68
80
|
// No status at all means the request itself failed (DNS, connection refused, timeout) before
|
|
@@ -75,6 +87,31 @@ function wrap(originalRequest, recordSpan) {
|
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Adds the traceparent header to a request that was just created and hasn't sent its headers yet
|
|
92
|
+
* (the caller only writes or ends it after http.request returns). Leaves a traceparent the caller
|
|
93
|
+
* already set alone: whoever set it explicitly knows better than this client which trace the call
|
|
94
|
+
* belongs to. Skipped when headers were already flushed (a raw-array `headers` option sends them at
|
|
95
|
+
* construction). Never throws, since a failure to add a header must never stop the host app's own
|
|
96
|
+
* request from going out.
|
|
97
|
+
*
|
|
98
|
+
* @param {import("node:http").ClientRequest} req
|
|
99
|
+
* @param {() => string | null} traceparent
|
|
100
|
+
*/
|
|
101
|
+
function propagateTrace(req, traceparent) {
|
|
102
|
+
try {
|
|
103
|
+
if (req.headersSent || req.hasHeader(TRACEPARENT_HEADER)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const value = traceparent();
|
|
107
|
+
if (value !== null) {
|
|
108
|
+
req.setHeader(TRACEPARENT_HEADER, value);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// See this function's own comment: the request goes out without the header.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Best-effort parse of whatever calling convention produced this request (a bare options object,
|
|
80
117
|
* a URL string/object plus an optional options object, either form optionally followed by a
|
|
@@ -82,15 +119,21 @@ function wrap(originalRequest, recordSpan) {
|
|
|
82
119
|
* path or query string a customer's own request carries (that could hold a customer's own id or
|
|
83
120
|
* a secret), the same reasoning every other transaction_name/span name in this client already
|
|
84
121
|
* follows.
|
|
122
|
+
*
|
|
123
|
+
* port/protocol are only ever used to recognize this client's own deliveries to ForgeOps (see
|
|
124
|
+
* Configuration#isOwnHost), never in a label.
|
|
85
125
|
* @param {unknown[]} args
|
|
126
|
+
* @param {string} defaultProtocol
|
|
86
127
|
*/
|
|
87
|
-
function describeRequest(args) {
|
|
128
|
+
function describeRequest(args, defaultProtocol) {
|
|
88
129
|
const [first, second] = args;
|
|
89
130
|
const options = {};
|
|
90
131
|
|
|
91
132
|
if (typeof first === "string" || first instanceof URL) {
|
|
92
133
|
const url = typeof first === "string" ? new URL(first) : first;
|
|
93
134
|
options.host = url.hostname;
|
|
135
|
+
options.port = url.port;
|
|
136
|
+
options.protocol = url.protocol;
|
|
94
137
|
if (second && typeof second === "object") {
|
|
95
138
|
Object.assign(options, second);
|
|
96
139
|
}
|
|
@@ -100,5 +143,5 @@ function describeRequest(args) {
|
|
|
100
143
|
|
|
101
144
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
102
145
|
const host = options.host ?? options.hostname ?? "unknown";
|
|
103
|
-
return { method, host };
|
|
146
|
+
return { method, host, port: options.port, protocol: options.protocol ?? defaultProtocol };
|
|
104
147
|
}
|