@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 +40 -6
- package/package.json +1 -1
- package/src/eventBuilder.js +11 -4
- package/src/index.js +39 -3
- package/src/integrations/express.js +50 -1
- package/src/reporter.js +3 -2
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 /
|
|
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
|
+
"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",
|
package/src/eventBuilder.js
CHANGED
|
@@ -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
|
|
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
|
|
113
|
-
|
|
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}`);
|