@forge-ops/tracker 0.3.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
@@ -37,10 +34,11 @@ overridden by passing it in the options object.
37
34
  ```js
38
35
  import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
39
36
  import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
40
- import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
37
+ import { forgeOpsTrackerUserContextMiddleware, forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
41
38
 
42
39
  app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
43
40
  app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
41
+ app.use(forgeOpsTrackerUserContextMiddleware); // after Passport's own session middleware, if used
44
42
  // ...routes...
45
43
  app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
46
44
  ```
@@ -111,6 +109,40 @@ themselves, long before it would ever reach here.
111
109
  Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
112
110
  dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
113
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
+
114
146
  ## Delivery: an async loop, not a thread
115
147
 
116
148
  `DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
@@ -143,7 +175,9 @@ By default, the message, backtrace, and any context/tags you attach are scanned
143
175
  for likely personal data: email addresses, formatted SSNs/credit cards, known API key/token
144
176
  formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar),
145
177
  and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
146
- 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.
147
181
 
148
182
  To disable it:
149
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.3.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",
@@ -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,3 +1,4 @@
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";
@@ -16,6 +17,17 @@ let processHandlersInstalled = false;
16
17
  let uncaughtExceptionListener = null;
17
18
  let unhandledRejectionListener = null;
18
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
+
19
31
  function getConfiguration() {
20
32
  if (configuration === null) {
21
33
  configuration = new Configuration();
@@ -104,13 +116,37 @@ export function init(options = {}) {
104
116
  }
105
117
 
106
118
  /**
107
- * 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.
108
122
  *
109
123
  * @param {Error} error
110
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}
111
147
  */
112
- export function captureException(error, context = {}) {
113
- getReporter().report(error, context);
148
+ export function runWithUser(user, callback) {
149
+ return userStorage.run({ user }, callback);
114
150
  }
115
151
 
116
152
  /**
@@ -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
+ }
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}`);