@forge-ops/tracker 0.2.0 → 0.5.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
@@ -7,11 +7,8 @@ events to ForgeOps over HTTP without blocking the request or process that raised
7
7
 
8
8
  ## Installation
9
9
 
10
- Not yet published to npm: install directly from this path (or a local checkout, once split into
11
- its own repo):
12
-
13
10
  ```bash
14
- npm install /path/to/forge_ops/sdks/node
11
+ npm install @forge-ops/tracker
15
12
  ```
16
13
 
17
14
  ## Configuration
@@ -36,9 +33,12 @@ overridden by passing it in the options object.
36
33
 
37
34
  ```js
38
35
  import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
39
- import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
36
+ import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
37
+ import { forgeOpsTrackerUserContextMiddleware, forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
40
38
 
41
39
  app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
40
+ app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
41
+ app.use(forgeOpsTrackerUserContextMiddleware); // after Passport's own session middleware, if used
42
42
  // ...routes...
43
43
  app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
44
44
  ```
@@ -109,6 +109,40 @@ themselves, long before it would ever reach here.
109
109
  Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
110
110
  dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
111
111
 
112
+ ## Identifying users
113
+
114
+ ```js
115
+ captureException(error, {}, { id: user.id, email: user.email });
116
+ ```
117
+
118
+ Or `runWithUser(user, callback)` to attach it to every `captureException()` call made anywhere in
119
+ `callback`'s own async chain, rather than passing it by hand every time, e.g. from your own
120
+ middleware:
121
+
122
+ ```js
123
+ app.use((req, res, next) => runWithUser({ id: req.user?.id, email: req.user?.email }, next));
124
+ ```
125
+
126
+ There's no imperative `setUser()` the way some other languages in this repo have: Node is
127
+ single-threaded, so a plain module-level variable would leak across concurrent requests
128
+ interleaved on the same event loop, exactly the bug the other languages' own thread-local choice
129
+ avoids for their own concurrency model. `AsyncLocalStorage` (Node's own built-in mechanism for
130
+ this) only propagates a value through an async call chain that was explicitly wrapped, so
131
+ `runWithUser`'s wrapping shape is the correct, idiomatic choice here, not a limitation being
132
+ worked around. `id`/`email`/`username` are all independently optional. Shows up on an issue's own
133
+ detail page, and as its own affected-users count alongside the regular event count.
134
+
135
+ **Express apps get this automatically**: add `forgeOpsTrackerUserContextMiddleware` (see the
136
+ Express snippet above), after whatever middleware actually sets `req.user` (Passport's own session
137
+ middleware, the closest thing Express has to a single dominant auth library, the same role Warden
138
+ plays for Rails). A no-op when `req.user` is never set, whether that's because nobody's signed in
139
+ or Passport isn't installed. Confirmed directly, with a real Express app and a real async route
140
+ handler, that the `AsyncLocalStorage` context this sets survives all the way through to
141
+ `forgeOpsTrackerExpressMiddleware` later in the same request, including across an `await`, not
142
+ just through a synchronous call chain. Composes with the manual API above rather than replacing
143
+ it: call `runWithUser` yourself for a route (or a custom auth setup this can't detect) that needs
144
+ to override what was auto-detected.
145
+
112
146
  ## Delivery: an async loop, not a thread
113
147
 
114
148
  `DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
@@ -141,7 +175,9 @@ By default, the message, backtrace, and any context/tags you attach are scanned
141
175
  for likely personal data: email addresses, formatted SSNs/credit cards, known API key/token
142
176
  formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar),
143
177
  and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
144
- regardless, so this is a second, earlier layer, not the only one.
178
+ regardless, so this is a second, earlier layer, not the only one. The user attached via
179
+ `captureException`'s third argument or `runWithUser` above is a deliberate exception: it's never
180
+ scrubbed, since redacting it would defeat the whole point of identifying users in the first place.
145
181
 
146
182
  To disable it:
147
183
 
@@ -197,6 +233,32 @@ shutdown if this tracker's own listener won that race. The accepted trade-off: u
197
233
  would be if the interval simply hadn't ticked yet: never a behavior change for whatever app this
198
234
  is installed into. See `src/sessionFlusher.js`'s own comment for the full reasoning.
199
235
 
236
+ ## Performance monitoring
237
+
238
+ By default, the Express integration times every request (`performance.now()` before the route
239
+ runs, diffed once the response finishes) so a dashboard widget on ForgeOps can show which parts
240
+ of your app are actually slow, not just which ones raise. Bucketed by transaction
241
+ (`"GET /users/:id"`, the matched route pattern rather than the literal URL, so a distinct user id
242
+ doesn't explode into its own separate transaction) and flushed as a small periodic aggregate per
243
+ transaction on the same kind of `setInterval` timer session tracking above uses.
244
+
245
+ ```js
246
+ forgeOpsTracker.init({
247
+ dsn: "...",
248
+ trackPerformance: false, // opt out entirely
249
+ performanceFlushIntervalMs: 30000, // default 60000
250
+ });
251
+ ```
252
+
253
+ Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
254
+ periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
255
+ failure. Same deliberate no-flush-on-`SIGTERM`/`SIGINT` gap as session tracking above, and for
256
+ the identical reason; see `src/performanceFlusher.js`'s own comment.
257
+
258
+ Fastify isn't supported yet: unlike session tracking, there's no existing request-lifecycle hook
259
+ to build this on for Fastify today, so it's a separate piece of work rather than something this
260
+ version already covers.
261
+
200
262
  ## Running the tests
201
263
 
202
264
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,7 +8,8 @@
8
8
  ".": "./src/index.js",
9
9
  "./integrations/express": "./src/integrations/express.js",
10
10
  "./integrations/fastify": "./src/integrations/fastify.js",
11
- "./integrations/session-tracking": "./src/integrations/sessionTracking.js"
11
+ "./integrations/session-tracking": "./src/integrations/sessionTracking.js",
12
+ "./integrations/performance": "./src/integrations/performance.js"
12
13
  },
13
14
  "engines": {
14
15
  "node": ">=18"
package/src/client.js CHANGED
@@ -28,6 +28,17 @@ export class Client {
28
28
  return this.#post(this.#configuration.sessionCheckinsUri(), payload);
29
29
  }
30
30
 
31
+ /**
32
+ * Same delivery contract again; samples is a batch (one entry per distinct transaction a
33
+ * flush interval saw), not a single aggregate the way deliverSessionCheckin's own payload is,
34
+ * so this posts { samples } rather than the array bare, matching what
35
+ * Api::V1::PerformanceSamplesController expects.
36
+ * @param {Record<string, unknown>[]} samples
37
+ */
38
+ async deliverPerformanceSamples(samples) {
39
+ return this.#post(this.#configuration.performanceSamplesUri(), { samples });
40
+ }
41
+
31
42
  /**
32
43
  * @param {string | null} uri
33
44
  * @param {Record<string, unknown>} payload
@@ -56,6 +56,15 @@ export class Configuration {
56
56
  * flushed as one small report on this interval, not one network call per request. */
57
57
  sessionFlushIntervalMs = 60000;
58
58
 
59
+ /**
60
+ * Whether the Express integration times every request's duration and periodically reports an
61
+ * aggregate per transaction. On by default, the same posture trackSessions above already has.
62
+ */
63
+ trackPerformance = true;
64
+ /** Milliseconds between aggregate performance reports; same "counted in-process, flushed as
65
+ * one small batch on this interval" reasoning as sessionFlushIntervalMs above. */
66
+ performanceFlushIntervalMs = 60000;
67
+
59
68
  /** @returns {string | null} */
60
69
  apiKey() {
61
70
  const parsed = this.#parsedDsn();
@@ -94,6 +103,18 @@ export class Configuration {
94
103
  return uri.replace(/\/events$/, "/session_checkins");
95
104
  }
96
105
 
106
+ /**
107
+ * Same derivation again, swapping the trailing /events for /performance_samples.
108
+ * @returns {string | null}
109
+ */
110
+ performanceSamplesUri() {
111
+ const uri = this.ingestionUri();
112
+ if (!uri) {
113
+ return null;
114
+ }
115
+ return uri.replace(/\/events$/, "/performance_samples");
116
+ }
117
+
97
118
  /** @returns {boolean} */
98
119
  isEnabled() {
99
120
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -46,8 +46,9 @@ export class EventBuilder {
46
46
  /**
47
47
  * @param {Error} error
48
48
  * @param {Record<string, unknown>} [context]
49
+ * @param {Record<string, unknown> | null} [user]
49
50
  */
50
- build(error, context = {}) {
51
+ build(error, context = {}, user = null) {
51
52
  const payload = {
52
53
  exception_class: error?.name ?? "Error",
53
54
  message: error?.message ?? "",
@@ -60,14 +61,20 @@ export class EventBuilder {
60
61
  tags: {},
61
62
  sdk_name: SDK_NAME,
62
63
  };
64
+ if (user && Object.keys(user).length > 0) {
65
+ payload.user = { ...user };
66
+ }
63
67
 
64
68
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
65
69
  }
66
70
 
67
- // exception_class/occurred_at/environment/release/server_name are left
68
- // alone: structured fields this client or the host app sets
71
+ // exception_class/occurred_at/environment/release/server_name/user are
72
+ // left alone: structured fields this client or the host app sets
69
73
  // deliberately, not free text an exception or its context could
70
- // accidentally spill sensitive data into.
74
+ // accidentally spill sensitive data into. user specifically is a
75
+ // deliberate exemption, not an oversight: the scrubber's own email
76
+ // pattern would otherwise redact the exact thing this field exists to
77
+ // carry.
71
78
  #scrub(payload) {
72
79
  return {
73
80
  ...payload,
package/src/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { Client } from "./client.js";
2
3
  import { Configuration } from "./configuration.js";
3
4
  import { DeliveryQueue } from "./deliveryQueue.js";
4
5
  import { EventBuilder } from "./eventBuilder.js";
6
+ import { PerformanceFlusher } from "./performanceFlusher.js";
5
7
  import { Reporter } from "./reporter.js";
6
8
  import { SessionFlusher } from "./sessionFlusher.js";
7
9
 
@@ -10,10 +12,22 @@ export { Configuration };
10
12
  let configuration = null;
11
13
  let reporter = null;
12
14
  let sessionFlusher = null;
15
+ let performanceFlusher = null;
13
16
  let processHandlersInstalled = false;
14
17
  let uncaughtExceptionListener = null;
15
18
  let unhandledRejectionListener = null;
16
19
 
20
+ // Request-scoped affected-user storage. AsyncLocalStorage, not a plain module-level variable the
21
+ // way gems/forge_ops_tracker uses Thread.current or Python uses threading.local: Node is
22
+ // single-threaded, so a plain variable would leak across concurrent requests being interleaved
23
+ // on the same event loop, exactly the bug those other languages' own thread-local choice avoids
24
+ // for their own concurrency model. AsyncLocalStorage is the Node-idiomatic equivalent, but its
25
+ // API shape is different on purpose: it only propagates a value through an async call chain that
26
+ // was explicitly wrapped via runWithUser() below (there's no imperative "just set it from
27
+ // anywhere" the way Thread.current allows), which is why this SDK's own set_user-equivalent is a
28
+ // wrapping function, not a bare setter.
29
+ const userStorage = new AsyncLocalStorage();
30
+
17
31
  function getConfiguration() {
18
32
  if (configuration === null) {
19
33
  configuration = new Configuration();
@@ -39,6 +53,14 @@ function getSessionFlusher() {
39
53
  return sessionFlusher;
40
54
  }
41
55
 
56
+ function getPerformanceFlusher() {
57
+ if (performanceFlusher === null) {
58
+ const config = getConfiguration();
59
+ performanceFlusher = new PerformanceFlusher(config, new Client(config));
60
+ }
61
+ return performanceFlusher;
62
+ }
63
+
42
64
  /**
43
65
  * Internal; called by the Express/Fastify session-tracking integrations, never by host app
44
66
  * code directly (there's nothing for a caller to decide here beyond what the middleware/hook
@@ -54,6 +76,21 @@ export function _recordSession(crashed) {
54
76
  getSessionFlusher().recordSession(crashed);
55
77
  }
56
78
 
79
+ /**
80
+ * Internal; called by the Express performance-tracking integration, never by host app code
81
+ * directly, same reasoning as _recordSession above.
82
+ *
83
+ * @param {string} transactionName
84
+ * @param {number} durationMs
85
+ */
86
+ export function _recordPerformance(transactionName, durationMs) {
87
+ const config = getConfiguration();
88
+ if (!config.trackPerformance || !config.isEnabled()) {
89
+ return;
90
+ }
91
+ getPerformanceFlusher().record(transactionName, durationMs);
92
+ }
93
+
57
94
  /**
58
95
  * Configure the client. Call once at startup.
59
96
  *
@@ -79,13 +116,37 @@ export function init(options = {}) {
79
116
  }
80
117
 
81
118
  /**
82
- * Report an exception you've already caught.
119
+ * Report an exception you've already caught. `user` defaults to whatever runWithUser() below
120
+ * established for this async call chain, if anything; pass one explicitly to override that for
121
+ * this one report.
83
122
  *
84
123
  * @param {Error} error
85
124
  * @param {Record<string, unknown>} [context]
125
+ * @param {Record<string, unknown> | null} [user]
126
+ */
127
+ export function captureException(error, context = {}, user = null) {
128
+ getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null);
129
+ }
130
+
131
+ /**
132
+ * Runs `callback` (sync or async) with an affected user attached to every `captureException()`
133
+ * call made anywhere in its call chain that doesn't pass its own explicit `user`, e.g. from your
134
+ * own middleware:
135
+ *
136
+ * app.use((req, res, next) => runWithUser({ id: req.user?.id }, next));
137
+ *
138
+ * There's no imperative `setUser()` the way other languages in this repo have: AsyncLocalStorage
139
+ * (see this module's own comment on `userStorage`) only propagates a value through an async call
140
+ * chain that was explicitly wrapped like this, so wrapping is the correct, idiomatic shape here,
141
+ * not a limitation being worked around.
142
+ *
143
+ * @template T
144
+ * @param {Record<string, unknown>} user
145
+ * @param {() => T} callback
146
+ * @returns {T}
86
147
  */
87
- export function captureException(error, context = {}) {
88
- getReporter().report(error, context);
148
+ export function runWithUser(user, callback) {
149
+ return userStorage.run({ user }, callback);
89
150
  }
90
151
 
91
152
  /**
@@ -137,6 +198,7 @@ export function _resetForTesting() {
137
198
  configuration = null;
138
199
  reporter = null;
139
200
  sessionFlusher = null;
201
+ performanceFlusher = null;
140
202
  processHandlersInstalled = false;
141
203
  uncaughtExceptionListener = null;
142
204
  unhandledRejectionListener = null;
@@ -1,4 +1,4 @@
1
- import { captureException } from "../index.js";
1
+ import { captureException, runWithUser } from "../index.js";
2
2
 
3
3
  /**
4
4
  * Express error-handling middleware. Register last, after all routes:
@@ -26,3 +26,52 @@ export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
26
26
  });
27
27
  next(err);
28
28
  }
29
+
30
+ /**
31
+ * Automatically identifies the affected user for every error reported for the rest of this
32
+ * request, when `req.user` is present. Register early, before your routes (and after whatever
33
+ * middleware actually sets `req.user`, e.g. Passport's own session middleware):
34
+ *
35
+ * app.use(passport.session());
36
+ * app.use(forgeOpsTrackerUserContextMiddleware);
37
+ * // ...routes...
38
+ * app.use(forgeOpsTrackerExpressMiddleware);
39
+ *
40
+ * `req.user` is Passport's own convention (the closest thing Express has to a single dominant
41
+ * auth library, the same role Warden plays for Rails): set once `passport.authenticate()`/session
42
+ * deserialization succeeds, left `undefined` otherwise, so a plain truthy check is enough, the
43
+ * same duck-typed "is there a user object at all" check `gems/forge_ops_tracker`'s own Warden
44
+ * integration does for `env["warden"].user`. A no-op for an app that never set `req.user` at all,
45
+ * whether that's because nobody's signed in or Passport (or an equivalent) isn't installed.
46
+ *
47
+ * Wraps the rest of the request in `runWithUser()` (see that function's own doc comment for why
48
+ * this SDK has no imperative `setUser()`) rather than setting anything imperatively itself:
49
+ * confirmed directly, with a real Express app and a real async route handler, that
50
+ * `AsyncLocalStorage`'s context set by an early middleware like this one does survive all the way
51
+ * through to a later error-handling middleware (`forgeOpsTrackerExpressMiddleware`, typically
52
+ * registered last, after every route) in the same request, not just to handlers registered
53
+ * synchronously right after this one.
54
+ */
55
+ export function forgeOpsTrackerUserContextMiddleware(req, res, next) {
56
+ if (!req.user) {
57
+ next();
58
+ return;
59
+ }
60
+ runWithUser(serializeUser(req.user), next);
61
+ }
62
+
63
+ function serializeUser(user) {
64
+ const result = {};
65
+ if (user.id !== undefined && user.id !== null) {
66
+ result.id = String(user.id);
67
+ }
68
+ if (user.email) {
69
+ result.email = user.email;
70
+ }
71
+ if (user.username) {
72
+ result.username = user.username;
73
+ } else if (user.name) {
74
+ result.username = user.name;
75
+ }
76
+ return result;
77
+ }
@@ -0,0 +1,34 @@
1
+ import { _recordPerformance } from "../index.js";
2
+
3
+ /**
4
+ * Express performance-tracking middleware. Register wherever is convenient relative to routes
5
+ * (unlike sessionTracking.js's own middleware, this doesn't need to run first: it only reads
6
+ * req.route once the request has already been routed, inside the "finish" listener below, not
7
+ * at request-start):
8
+ *
9
+ * app.use(forgeOpsTrackerPerformanceExpressMiddleware);
10
+ *
11
+ * Times every request (performance.now() before next(), diffed inside res.on("finish")) so a
12
+ * dashboard widget on ForgeOps can show which parts of your app are actually slow, not just
13
+ * which ones raise. A separate, independent middleware from both the error and session-tracking
14
+ * ones, same "several genuinely independent mechanisms" reasoning sessionTracking.js's own
15
+ * comment documents.
16
+ *
17
+ * transactionName is "<HTTP method> <route pattern>", e.g. "GET /users/:id", not the raw URL:
18
+ * req.route.path is Express's own matched route pattern, read here (after routing has actually
19
+ * happened, unlike at request-start where it isn't populated yet), which keeps a distinct user
20
+ * id from exploding into its own separate transaction the way the literal URL would -- the
21
+ * low-cardinality equivalent of a Rails controller#action. Falls back to req.path (the literal,
22
+ * unmatched path) for a request that never matched a route at all (a 404).
23
+ */
24
+ export function forgeOpsTrackerPerformanceExpressMiddleware(req, res, next) {
25
+ const start = performance.now();
26
+
27
+ res.on("finish", () => {
28
+ const durationMs = performance.now() - start;
29
+ const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
30
+ _recordPerformance(transactionName, durationMs);
31
+ });
32
+
33
+ next();
34
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Times requests in-process, bucketed by transactionName (see integrations/performance.js), and
3
+ * periodically flushes each distinct bucket as one small aggregate report, rather than one
4
+ * network call per request. Ported from
5
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/performance_flusher.rb; structurally the same
6
+ * shape as sessionFlusher.js right next to it, just keyed by a Map of buckets instead of two
7
+ * scalar counters.
8
+ *
9
+ * setInterval(...).unref(), not a real background thread, same reasoning sessionFlusher.js's own
10
+ * header comment documents. Also deliberately does NOT hook SIGTERM/SIGINT to force a final
11
+ * flush on exit, for the exact same reason sessionFlusher.js already rejects that: registering
12
+ * either listener overrides Node's own default exit behavior, a real correctness risk to the
13
+ * host app's own graceful shutdown, not just a data-completeness one. The accepted trade-off is
14
+ * the same too: up to one performanceFlushIntervalMs window of data can be lost on a hard
15
+ * process exit, a bounded, honest gap rather than a behavior change for the host app.
16
+ */
17
+ export class PerformanceFlusher {
18
+ #configuration;
19
+ #client;
20
+ #buckets = new Map();
21
+ #periodStartedAt = new Date();
22
+ #timer = null;
23
+
24
+ constructor(configuration, client) {
25
+ this.#configuration = configuration;
26
+ this.#client = client;
27
+ }
28
+
29
+ /**
30
+ * @param {string} transactionName
31
+ * @param {number} durationMs
32
+ */
33
+ record(transactionName, durationMs) {
34
+ this.#ensureTimerStarted();
35
+
36
+ const bucket = this.#buckets.get(transactionName) ?? { count: 0, durationSumMs: 0, maxDurationMs: 0 };
37
+ bucket.count += 1;
38
+ bucket.durationSumMs += durationMs;
39
+ if (durationMs > bucket.maxDurationMs) {
40
+ bucket.maxDurationMs = durationMs;
41
+ }
42
+ this.#buckets.set(transactionName, bucket);
43
+ }
44
+
45
+ /**
46
+ * Snapshots and resets the buckets, then delivers them as one batch. A failed delivery keeps
47
+ * every bucket where it is rather than resetting, so the next flush's batch just grows instead
48
+ * of losing what was already tallied; same reasoning sessionFlusher.js's own flush() documents.
49
+ */
50
+ async flush() {
51
+ if (this.#buckets.size === 0) {
52
+ return;
53
+ }
54
+
55
+ const periodEndedAt = new Date();
56
+ const samples = [...this.#buckets.entries()].map(([transactionName, bucket]) => ({
57
+ transaction_name: transactionName,
58
+ environment: this.#configuration.environment,
59
+ release: this.#configuration.release,
60
+ period_started_at: this.#periodStartedAt.toISOString(),
61
+ period_ended_at: periodEndedAt.toISOString(),
62
+ request_count: bucket.count,
63
+ duration_sum_ms: bucket.durationSumMs,
64
+ max_duration_ms: bucket.maxDurationMs,
65
+ }));
66
+
67
+ const delivered = await this.#client.deliverPerformanceSamples(samples);
68
+ if (!delivered) {
69
+ return;
70
+ }
71
+
72
+ this.#buckets = new Map();
73
+ this.#periodStartedAt = periodEndedAt;
74
+ }
75
+
76
+ #ensureTimerStarted() {
77
+ if (this.#timer !== null) {
78
+ return;
79
+ }
80
+
81
+ this.#timer = setInterval(() => {
82
+ this.flush().catch((e) => {
83
+ this.#configuration.log(`[forge-ops-tracker] performance flush error: ${e.name}: ${e.message}`);
84
+ });
85
+ }, this.#configuration.performanceFlushIntervalMs);
86
+ this.#timer.unref();
87
+ }
88
+ }
package/src/reporter.js CHANGED
@@ -20,14 +20,15 @@ export class Reporter {
20
20
  /**
21
21
  * @param {Error} error
22
22
  * @param {Record<string, unknown>} [context]
23
+ * @param {Record<string, unknown> | null} [user]
23
24
  */
24
- report(error, context = {}) {
25
+ report(error, context = {}, user = null) {
25
26
  try {
26
27
  if (!this.#configuration.isEnabled()) {
27
28
  return;
28
29
  }
29
30
 
30
- const payload = this.#eventBuilder.build(error, context);
31
+ const payload = this.#eventBuilder.build(error, context, user);
31
32
  this.#deliveryQueue.push(payload);
32
33
  } catch (e) {
33
34
  this.#configuration.log(`[forge-ops-tracker] report failed: ${e.name}: ${e.message}`);