@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 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.2.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
@@ -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
+ }