@forge-ops/tracker 0.2.0 → 0.3.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 +28 -0
- package/package.json +3 -2
- package/src/client.js +11 -0
- package/src/configuration.js +21 -0
- package/src/index.js +26 -0
- package/src/integrations/performance.js +34 -0
- package/src/performanceFlusher.js +88 -0
package/README.md
CHANGED
|
@@ -36,9 +36,11 @@ overridden by passing it in the options object.
|
|
|
36
36
|
|
|
37
37
|
```js
|
|
38
38
|
import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
|
|
39
|
+
import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
|
|
39
40
|
import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
|
|
40
41
|
|
|
41
42
|
app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
|
|
43
|
+
app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
|
|
42
44
|
// ...routes...
|
|
43
45
|
app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
|
|
44
46
|
```
|
|
@@ -197,6 +199,32 @@ shutdown if this tracker's own listener won that race. The accepted trade-off: u
|
|
|
197
199
|
would be if the interval simply hadn't ticked yet: never a behavior change for whatever app this
|
|
198
200
|
is installed into. See `src/sessionFlusher.js`'s own comment for the full reasoning.
|
|
199
201
|
|
|
202
|
+
## Performance monitoring
|
|
203
|
+
|
|
204
|
+
By default, the Express integration times every request (`performance.now()` before the route
|
|
205
|
+
runs, diffed once the response finishes) so a dashboard widget on ForgeOps can show which parts
|
|
206
|
+
of your app are actually slow, not just which ones raise. Bucketed by transaction
|
|
207
|
+
(`"GET /users/:id"`, the matched route pattern rather than the literal URL, so a distinct user id
|
|
208
|
+
doesn't explode into its own separate transaction) and flushed as a small periodic aggregate per
|
|
209
|
+
transaction on the same kind of `setInterval` timer session tracking above uses.
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
forgeOpsTracker.init({
|
|
213
|
+
dsn: "...",
|
|
214
|
+
trackPerformance: false, // opt out entirely
|
|
215
|
+
performanceFlushIntervalMs: 30000, // default 60000
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
|
|
220
|
+
periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
|
|
221
|
+
failure. Same deliberate no-flush-on-`SIGTERM`/`SIGINT` gap as session tracking above, and for
|
|
222
|
+
the identical reason; see `src/performanceFlusher.js`'s own comment.
|
|
223
|
+
|
|
224
|
+
Fastify isn't supported yet: unlike session tracking, there's no existing request-lifecycle hook
|
|
225
|
+
to build this on for Fastify today, so it's a separate piece of work rather than something this
|
|
226
|
+
version already covers.
|
|
227
|
+
|
|
200
228
|
## Running the tests
|
|
201
229
|
|
|
202
230
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Client } from "./client.js";
|
|
|
2
2
|
import { Configuration } from "./configuration.js";
|
|
3
3
|
import { DeliveryQueue } from "./deliveryQueue.js";
|
|
4
4
|
import { EventBuilder } from "./eventBuilder.js";
|
|
5
|
+
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
5
6
|
import { Reporter } from "./reporter.js";
|
|
6
7
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ export { Configuration };
|
|
|
10
11
|
let configuration = null;
|
|
11
12
|
let reporter = null;
|
|
12
13
|
let sessionFlusher = null;
|
|
14
|
+
let performanceFlusher = null;
|
|
13
15
|
let processHandlersInstalled = false;
|
|
14
16
|
let uncaughtExceptionListener = null;
|
|
15
17
|
let unhandledRejectionListener = null;
|
|
@@ -39,6 +41,14 @@ function getSessionFlusher() {
|
|
|
39
41
|
return sessionFlusher;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function getPerformanceFlusher() {
|
|
45
|
+
if (performanceFlusher === null) {
|
|
46
|
+
const config = getConfiguration();
|
|
47
|
+
performanceFlusher = new PerformanceFlusher(config, new Client(config));
|
|
48
|
+
}
|
|
49
|
+
return performanceFlusher;
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
/**
|
|
43
53
|
* Internal; called by the Express/Fastify session-tracking integrations, never by host app
|
|
44
54
|
* code directly (there's nothing for a caller to decide here beyond what the middleware/hook
|
|
@@ -54,6 +64,21 @@ export function _recordSession(crashed) {
|
|
|
54
64
|
getSessionFlusher().recordSession(crashed);
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Internal; called by the Express performance-tracking integration, never by host app code
|
|
69
|
+
* directly, same reasoning as _recordSession above.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} transactionName
|
|
72
|
+
* @param {number} durationMs
|
|
73
|
+
*/
|
|
74
|
+
export function _recordPerformance(transactionName, durationMs) {
|
|
75
|
+
const config = getConfiguration();
|
|
76
|
+
if (!config.trackPerformance || !config.isEnabled()) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
getPerformanceFlusher().record(transactionName, durationMs);
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* Configure the client. Call once at startup.
|
|
59
84
|
*
|
|
@@ -137,6 +162,7 @@ export function _resetForTesting() {
|
|
|
137
162
|
configuration = null;
|
|
138
163
|
reporter = null;
|
|
139
164
|
sessionFlusher = null;
|
|
165
|
+
performanceFlusher = null;
|
|
140
166
|
processHandlersInstalled = false;
|
|
141
167
|
uncaughtExceptionListener = null;
|
|
142
168
|
unhandledRejectionListener = null;
|
|
@@ -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
|
+
}
|