@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 +68 -6
- package/package.json +3 -2
- package/src/client.js +11 -0
- package/src/configuration.js +21 -0
- package/src/eventBuilder.js +11 -4
- package/src/index.js +65 -3
- package/src/integrations/express.js +50 -1
- package/src/integrations/performance.js +34 -0
- package/src/performanceFlusher.js +88 -0
- 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
|
|
@@ -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 {
|
|
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.
|
|
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
|
package/src/configuration.js
CHANGED
|
@@ -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);
|
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,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
|
|
88
|
-
|
|
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}`);
|