@forge-ops/tracker 0.11.0 → 0.13.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
@@ -489,10 +539,39 @@ app.use(forgeOpsTrackerExpressMiddleware);
489
539
  app.listen(3000);
490
540
  ```
491
541
 
492
- **Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
493
- of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
494
- here either); a database call or Redis call inside a traced request just won't show up as its own
495
- span for now.
542
+ ### Database spans with their SQL
543
+
544
+ Database queries aren't captured automatically (this SDK has no query or ORM instrumentation hook
545
+ to extend), so wrap a query by hand with `kind: "database"` to have it show up as its own span. Pass
546
+ the SQL as `statement` and ForgeOps shows which query a slow request spent its time in:
547
+
548
+ ```js
549
+ import pg from "pg";
550
+ import * as forgeOpsTracker from "@forge-ops/tracker";
551
+
552
+ const pool = new pg.Pool();
553
+
554
+ app.get("/orders", async (req, res) => {
555
+ const sql = "SELECT id, total FROM orders WHERE customer_id = $1 AND status = 'open'";
556
+ const { rows } = await forgeOpsTracker.span("Load open orders", () => pool.query(sql, [req.user.id]), {
557
+ kind: "database",
558
+ statement: sql,
559
+ dbSystem: "postgresql",
560
+ });
561
+ res.json(rows);
562
+ });
563
+ ```
564
+
565
+ The statement is masked before it's stored on the span: every string and number becomes `?`, so the
566
+ span above carries `SELECT id, total FROM orders WHERE customer_id = $1 AND status = ?`, and
567
+ ForgeOps masks it again on arrival. It's cut to 4000 characters. Bind values (the `[req.user.id]`
568
+ array) are never read or sent. `dbSystem` is optional (`"postgresql"`, `"mysql"`, `"sqlite"`,
569
+ `"mssql"`, `"oracle"`, or any other lowercase name) and both options are ignored on spans of any
570
+ other kind. They go out in the span's data as `db.statement` and `db.system`.
571
+
572
+ **Known gaps:** no automatic database span capture (see above) and no Redis span capture (no
573
+ existing Redis hook or dependency exists here either); a Redis call inside a traced request just
574
+ won't show up as its own span for now.
496
575
 
497
576
  ## Database errors
498
577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.11.0",
3
+ "version": "0.13.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";
@@ -11,6 +12,7 @@ import { Reporter } from "./reporter.js";
11
12
  import { requestStateFor } from "./requestState.js";
12
13
  import { SessionFlusher } from "./sessionFlusher.js";
13
14
  import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
15
+ import { mask as maskSql } from "./sqlStatement.js";
14
16
  import { buildTraceparent } from "./traceParent.js";
15
17
 
16
18
  export { Configuration };
@@ -21,6 +23,8 @@ let reporter = null;
21
23
  let sessionFlusher = null;
22
24
  let performanceFlusher = null;
23
25
  let spanQueue = null;
26
+ let changeQueue = null;
27
+ let changeSnapshotStarted = false;
24
28
  let metricBuffer = null;
25
29
  let infrastructureMetricBuffer = null;
26
30
  let processHandlersInstalled = false;
@@ -183,6 +187,70 @@ export async function flushMetrics() {
183
187
  await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
184
188
  }
185
189
 
190
+ function getChangeQueue() {
191
+ if (changeQueue === null) {
192
+ const config = getConfiguration();
193
+ changeQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverChange", label: "change" });
194
+ }
195
+ return changeQueue;
196
+ }
197
+
198
+ /**
199
+ * Records one thing that changed in a running system, so ForgeOps can show it next to the errors
200
+ * and slowdowns that followed:
201
+ *
202
+ * recordChange({ kind: "feature_flag", title: "Enabled new_checkout for 10%", details: { rollout: 10 } });
203
+ *
204
+ * `kind` is one of feature_flag, config, migration, dependency, infrastructure, or other (anything
205
+ * else is sent as "other"). `environment` defaults to the configured one; `occurredAt` (a Date or an
206
+ * ISO 8601 string) defaults to now; `id` is an optional idempotency key. Queued and delivered on the
207
+ * same async loop as error events, so it never blocks the caller. A no-op when the client isn't
208
+ * enabled. Never throws: returns false when nothing was queued.
209
+ *
210
+ * @param {{ kind: string, title: string, details?: Record<string, unknown>, environment?: string,
211
+ * service?: string, actor?: string, url?: string, id?: string, occurredAt?: Date | string }} change
212
+ * @returns {boolean}
213
+ */
214
+ export function recordChange(change) {
215
+ try {
216
+ const config = getConfiguration();
217
+ if (!config.isEnabled()) {
218
+ return false;
219
+ }
220
+ const payload = buildChange(config, change ?? {});
221
+ if (payload === null) {
222
+ return false;
223
+ }
224
+ return getChangeQueue().push(payload);
225
+ } catch (e) {
226
+ getConfiguration().log(`[forge-ops-tracker] recordChange failed: ${e.name}: ${e.message}`);
227
+ return false;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Sends the one startup snapshot this process sends (see changes.js), on a later event-loop turn so
233
+ * init() returns immediately and startup never waits on it. A second call is a no-op. Does nothing,
234
+ * and doesn't use up the once, when the client isn't enabled or detectChanges is off. The timer is
235
+ * unref'd, so a short script that's otherwise done doesn't stay alive just to send it. Never throws.
236
+ */
237
+ function startChangeSnapshot(config) {
238
+ if (changeSnapshotStarted || !config.isEnabled() || !config.detectChanges) {
239
+ return false;
240
+ }
241
+ changeSnapshotStarted = true;
242
+
243
+ const timer = setTimeout(async () => {
244
+ try {
245
+ await new Client(config).deliverChangeSnapshot(await buildSnapshot(config));
246
+ } catch (e) {
247
+ config.log(`[forge-ops-tracker] change snapshot failed: ${e.name}: ${e.message}`);
248
+ }
249
+ }, 0);
250
+ timer.unref?.();
251
+ return true;
252
+ }
253
+
186
254
  /**
187
255
  * A DeliveryQueue reused for spans (see that class's own comment for why it takes a
188
256
  * deliverMethod/label rather than needing a whole second, near-identical class the way
@@ -442,6 +510,8 @@ export function init(options = {}) {
442
510
  // _recordSpan above), never at install time.
443
511
  installHttpTracing(_recordSpan, _traceparentFor);
444
512
 
513
+ startChangeSnapshot(config);
514
+
445
515
  return config;
446
516
  }
447
517
 
@@ -541,17 +611,30 @@ export function runWithUser(user, callback) {
541
611
  * request-level trace around it, and no middleware left running to ever flush it, would just
542
612
  * accumulate in memory forever with nothing to ever send it, worse than not recording it at all.
543
613
  *
614
+ * A "database" span can also carry the SQL it ran, so ForgeOps can show which query was slow:
615
+ *
616
+ * await forgeOpsTracker.span("Load orders", () => pool.query(sql, [userId]), {
617
+ * kind: "database",
618
+ * statement: sql,
619
+ * dbSystem: "postgresql",
620
+ * });
621
+ *
622
+ * The statement is masked (every string and number replaced by "?") before it's ever stored on
623
+ * the span, and sent as data["db.statement"], with dbSystem lowercased as data["db.system"]. Bind
624
+ * values are never taken, and both options are ignored on any other kind of span.
625
+ *
544
626
  * @template T
545
627
  * @param {string} name
546
628
  * @param {() => T} callback
547
- * @param {{ kind?: string, data?: Record<string, unknown> }} [options]
629
+ * @param {{ kind?: string, data?: Record<string, unknown>, statement?: string, dbSystem?: string }} [options]
548
630
  * @returns {T}
549
631
  */
550
- export function span(name, callback, { kind = "service", data = {} } = {}) {
632
+ export function span(name, callback, { kind = "service", data = {}, statement, dbSystem } = {}) {
551
633
  const buffer = spanStorage.getStore();
552
634
  if (!buffer) {
553
635
  return callback();
554
636
  }
637
+ data = databaseSpanData(kind, data, statement, dbSystem);
555
638
 
556
639
  const spanId = randomSpanId();
557
640
  const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
@@ -583,6 +666,33 @@ export function span(name, callback, { kind = "service", data = {} } = {}) {
583
666
  }
584
667
  }
585
668
 
669
+ /**
670
+ * The data a span is recorded with: unchanged unless it's a database span, which gets the masked
671
+ * statement as "db.statement" and the lowercased database name as "db.system". A "db.statement"
672
+ * passed in `data` directly is masked as well, so the raw SQL can't reach the payload either way.
673
+ * @param {string} kind
674
+ * @param {Record<string, unknown>} data
675
+ * @param {string | undefined} statement
676
+ * @param {string | undefined} dbSystem
677
+ * @returns {Record<string, unknown>}
678
+ */
679
+ function databaseSpanData(kind, data, statement, dbSystem) {
680
+ if (kind !== "database") {
681
+ return data;
682
+ }
683
+ const result = { ...data };
684
+ const masked = maskSql(typeof statement === "string" ? statement : result["db.statement"]);
685
+ if (masked === null) {
686
+ delete result["db.statement"];
687
+ } else {
688
+ result["db.statement"] = masked;
689
+ }
690
+ if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
691
+ result["db.system"] = dbSystem.trim().toLowerCase();
692
+ }
693
+ return result;
694
+ }
695
+
586
696
  /**
587
697
  * Reports anything that would otherwise crash the process outright (a
588
698
  * plain script, an unhandled promise rejection) with no further wiring:
@@ -621,8 +731,13 @@ function installProcessLevelHandlers() {
621
731
  process.on("unhandledRejection", unhandledRejectionListener);
622
732
  }
623
733
 
624
- /** @internal not part of the public API: resets module state between test cases */
625
- export function _resetForTesting() {
734
+ /**
735
+ * @internal not part of the public API: resets module state between test cases. The startup change
736
+ * snapshot is left marked as already sent, so the many tests that init() an enabled client and
737
+ * count fetch calls don't see an extra one; pass { changeSnapshot: true } to let the next init()
738
+ * send it (see test/changes.test.js).
739
+ */
740
+ export function _resetForTesting({ changeSnapshot = false } = {}) {
626
741
  if (uncaughtExceptionListener) {
627
742
  process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
628
743
  }
@@ -634,6 +749,8 @@ export function _resetForTesting() {
634
749
  sessionFlusher = null;
635
750
  performanceFlusher = null;
636
751
  spanQueue = null;
752
+ changeQueue = null;
753
+ changeSnapshotStarted = !changeSnapshot;
637
754
  metricBuffer?.discard();
638
755
  infrastructureMetricBuffer?.discard();
639
756
  metricBuffer = null;