@forge-ops/tracker 0.11.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
@@ -375,6 +375,56 @@ flush and would otherwise grow it for as long as the process lives. A NaN or inf
375
375
  dropped at capture: `JSON.stringify` turns it into `null` and the server would reject the whole
376
376
  batch behind it. Requires a ForgeOps plan that includes custom metrics / infrastructure monitoring.
377
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
+
378
428
  ## Distributed tracing
379
429
 
380
430
  For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.11.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
@@ -136,6 +136,21 @@ export class Configuration {
136
136
  */
137
137
  tracePropagationTargets = null;
138
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
+
139
154
  /** @returns {string | null} */
140
155
  apiKey() {
141
156
  const parsed = this.#parsedDsn();
@@ -223,6 +238,31 @@ export class Configuration {
223
238
  return uri.replace(/\/events$/, "/spans");
224
239
  }
225
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
+
226
266
  /**
227
267
  * Whether an outbound request to `host` should carry a traceparent header; see
228
268
  * propagateTraces/tracePropagationTargets above. Case-insensitive, since hostnames are.
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
3
+ import { buildChange, buildSnapshot } from "./changes.js";
3
4
  import { Client } from "./client.js";
4
5
  import { Configuration } from "./configuration.js";
5
6
  import { DeliveryQueue } from "./deliveryQueue.js";
@@ -21,6 +22,8 @@ let reporter = null;
21
22
  let sessionFlusher = null;
22
23
  let performanceFlusher = null;
23
24
  let spanQueue = null;
25
+ let changeQueue = null;
26
+ let changeSnapshotStarted = false;
24
27
  let metricBuffer = null;
25
28
  let infrastructureMetricBuffer = null;
26
29
  let processHandlersInstalled = false;
@@ -183,6 +186,70 @@ export async function flushMetrics() {
183
186
  await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
184
187
  }
185
188
 
189
+ function getChangeQueue() {
190
+ if (changeQueue === null) {
191
+ const config = getConfiguration();
192
+ changeQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverChange", label: "change" });
193
+ }
194
+ return changeQueue;
195
+ }
196
+
197
+ /**
198
+ * Records one thing that changed in a running system, so ForgeOps can show it next to the errors
199
+ * and slowdowns that followed:
200
+ *
201
+ * recordChange({ kind: "feature_flag", title: "Enabled new_checkout for 10%", details: { rollout: 10 } });
202
+ *
203
+ * `kind` is one of feature_flag, config, migration, dependency, infrastructure, or other (anything
204
+ * else is sent as "other"). `environment` defaults to the configured one; `occurredAt` (a Date or an
205
+ * ISO 8601 string) defaults to now; `id` is an optional idempotency key. Queued and delivered on the
206
+ * same async loop as error events, so it never blocks the caller. A no-op when the client isn't
207
+ * enabled. Never throws: returns false when nothing was queued.
208
+ *
209
+ * @param {{ kind: string, title: string, details?: Record<string, unknown>, environment?: string,
210
+ * service?: string, actor?: string, url?: string, id?: string, occurredAt?: Date | string }} change
211
+ * @returns {boolean}
212
+ */
213
+ export function recordChange(change) {
214
+ try {
215
+ const config = getConfiguration();
216
+ if (!config.isEnabled()) {
217
+ return false;
218
+ }
219
+ const payload = buildChange(config, change ?? {});
220
+ if (payload === null) {
221
+ return false;
222
+ }
223
+ return getChangeQueue().push(payload);
224
+ } catch (e) {
225
+ getConfiguration().log(`[forge-ops-tracker] recordChange failed: ${e.name}: ${e.message}`);
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Sends the one startup snapshot this process sends (see changes.js), on a later event-loop turn so
232
+ * init() returns immediately and startup never waits on it. A second call is a no-op. Does nothing,
233
+ * and doesn't use up the once, when the client isn't enabled or detectChanges is off. The timer is
234
+ * unref'd, so a short script that's otherwise done doesn't stay alive just to send it. Never throws.
235
+ */
236
+ function startChangeSnapshot(config) {
237
+ if (changeSnapshotStarted || !config.isEnabled() || !config.detectChanges) {
238
+ return false;
239
+ }
240
+ changeSnapshotStarted = true;
241
+
242
+ const timer = setTimeout(async () => {
243
+ try {
244
+ await new Client(config).deliverChangeSnapshot(await buildSnapshot(config));
245
+ } catch (e) {
246
+ config.log(`[forge-ops-tracker] change snapshot failed: ${e.name}: ${e.message}`);
247
+ }
248
+ }, 0);
249
+ timer.unref?.();
250
+ return true;
251
+ }
252
+
186
253
  /**
187
254
  * A DeliveryQueue reused for spans (see that class's own comment for why it takes a
188
255
  * deliverMethod/label rather than needing a whole second, near-identical class the way
@@ -442,6 +509,8 @@ export function init(options = {}) {
442
509
  // _recordSpan above), never at install time.
443
510
  installHttpTracing(_recordSpan, _traceparentFor);
444
511
 
512
+ startChangeSnapshot(config);
513
+
445
514
  return config;
446
515
  }
447
516
 
@@ -621,8 +690,13 @@ function installProcessLevelHandlers() {
621
690
  process.on("unhandledRejection", unhandledRejectionListener);
622
691
  }
623
692
 
624
- /** @internal not part of the public API: resets module state between test cases */
625
- export function _resetForTesting() {
693
+ /**
694
+ * @internal not part of the public API: resets module state between test cases. The startup change
695
+ * snapshot is left marked as already sent, so the many tests that init() an enabled client and
696
+ * count fetch calls don't see an extra one; pass { changeSnapshot: true } to let the next init()
697
+ * send it (see test/changes.test.js).
698
+ */
699
+ export function _resetForTesting({ changeSnapshot = false } = {}) {
626
700
  if (uncaughtExceptionListener) {
627
701
  process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
628
702
  }
@@ -634,6 +708,8 @@ export function _resetForTesting() {
634
708
  sessionFlusher = null;
635
709
  performanceFlusher = null;
636
710
  spanQueue = null;
711
+ changeQueue = null;
712
+ changeSnapshotStarted = !changeSnapshot;
637
713
  metricBuffer?.discard();
638
714
  infrastructureMetricBuffer?.discard();
639
715
  metricBuffer = null;