@forge-ops/tracker 0.1.1 → 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
@@ -1,13 +1,13 @@
1
1
  # @forge-ops/tracker
2
2
 
3
- Node.js error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
3
+ Node.js error reporting client for a [ForgeOps](../../) instance.
4
4
  Requires Node 18+ (for global `fetch`). It captures uncaught exceptions, unhandled promise
5
5
  rejections, and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers
6
6
  events to ForgeOps over HTTP without blocking the request or process that raised them.
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
10
+ Not yet published to npm: install directly from this path (or a local checkout, once split into
11
11
  its own repo):
12
12
 
13
13
  ```bash
@@ -35,12 +35,17 @@ overridden by passing it in the options object.
35
35
  ### Express
36
36
 
37
37
  ```js
38
+ import { forgeOpsTrackerSessionTrackingExpressMiddleware } from "@forge-ops/tracker/integrations/session-tracking";
39
+ import { forgeOpsTrackerPerformanceExpressMiddleware } from "@forge-ops/tracker/integrations/performance";
38
40
  import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
39
41
 
40
- app.use(forgeOpsTrackerExpressMiddleware); // last, after all routes
42
+ app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
43
+ app.use(forgeOpsTrackerPerformanceExpressMiddleware); // order relative to routes doesn't matter
44
+ // ...routes...
45
+ app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
41
46
  ```
42
47
 
43
- Requires Express 5+ -- its automatic forwarding of both synchronous throws *and* rejected promises
48
+ Requires Express 5+: its automatic forwarding of both synchronous throws *and* rejected promises
44
49
  from `async` route handlers to error-handling middleware is what makes this work with zero other
45
50
  wiring (verified directly against a real async handler, not assumed). Express 4 does not do this
46
51
  for async handlers; each route would need its own try/catch there instead.
@@ -54,7 +59,7 @@ registerForgeOpsTracker(app); // called directly, not via app.register()
54
59
  ```
55
60
 
56
61
  Called directly on your Fastify instance rather than through `app.register()`, which would create
57
- a new encapsulation scope (and require the `fastify-plugin` package to break out of it) -- this
62
+ a new encapsulation scope (and require the `fastify-plugin` package to break out of it): this
58
63
  avoids that entirely.
59
64
 
60
65
  ## What gets reported automatically, and what doesn't
@@ -63,7 +68,7 @@ avoids that entirely.
63
68
  report anything that propagates uncaught out of a route handler, then let the framework handle it
64
69
  exactly as if this client weren't installed.
65
70
 
66
- **An exception your own code catches and handles is different -- neither integration ever sees
71
+ **An exception your own code catches and handles is different: neither integration ever sees
67
72
  it**, since it never propagates far enough to reach either hook:
68
73
 
69
74
  ```js
@@ -71,13 +76,13 @@ try {
71
76
  await chargeCard(order);
72
77
  } catch (err) {
73
78
  logger.warn(`card declined: ${err.message}`);
74
- // ForgeOps never sees this -- caught locally, never reaches the
79
+ // ForgeOps never sees this: caught locally, never reaches the
75
80
  // middleware/hook at all.
76
81
  }
77
82
  ```
78
83
 
79
84
  There's no application-wide hook that reports an exception while still letting your own catch
80
- block handle it -- report it explicitly instead, right at the catch site:
85
+ block handle it: report it explicitly instead, right at the catch site:
81
86
 
82
87
  ```js
83
88
  } catch (err) {
@@ -91,28 +96,28 @@ block handle it -- report it explicitly instead, right at the catch site:
91
96
  `init()` also installs `process` event listeners by default (`installProcessHandlers: false` to
92
97
  opt out) covering two cases with no wiring needed:
93
98
 
94
- - **`uncaughtExceptionMonitor`**, not `uncaughtException` -- deliberately. Registering any listener
99
+ - **`uncaughtExceptionMonitor`**, not `uncaughtException`: deliberately. Registering any listener
95
100
  for `uncaughtException` *suppresses Node's own default crash behavior entirely* (the process
96
101
  would no longer exit on its own; Node's docs are explicit that resuming normal operation
97
102
  afterward isn't safe). `uncaughtExceptionMonitor` exists specifically for observability tools
98
- like this one: it fires without changing what Node does afterward -- verified directly against a
103
+ like this one: it fires without changing what Node does afterward, verified directly against a
99
104
  real uncaught throw, not assumed.
100
- - **`unhandledRejection`** -- a rejected promise nobody awaited or attached a `.catch()` to,
105
+ - **`unhandledRejection`**: a rejected promise nobody awaited or attached a `.catch()` to,
101
106
  arguably the most common way a modern async Node app silently fails.
102
107
 
103
- Neither catches a web request's unhandled exception under Express/Fastify -- both catch that
108
+ Neither catches a web request's unhandled exception under Express/Fastify: both catch that
104
109
  themselves, long before it would ever reach here.
105
110
 
106
- Every failure mode -- network errors, timeouts, a full queue, a malformed DSN -- is caught and
111
+ Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
107
112
  dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
108
113
 
109
114
  ## Delivery: an async loop, not a thread
110
115
 
111
- `DeliveryQueue` here isn't a background *thread* -- Node is single-threaded. But a Node process is
116
+ `DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
112
117
  long-running across many requests, so an async processing loop on the event loop is the natural
113
118
  substitute: `push()` returns immediately, and delivery happens via non-blocking `fetch()` calls
114
119
  without ever blocking the request that pushed it. The loop starts lazily, on first push, not at
115
- import time -- Node's `cluster` module can fork worker processes *after* the application has
120
+ import time: Node's `cluster` module can fork worker processes *after* the application has
116
121
  already loaded, and an eagerly-started loop would be left dead in every forked child; starting
117
122
  fresh on first push means each forked worker gets its own live loop regardless of when it was
118
123
  forked relative to import.
@@ -127,7 +132,7 @@ internal modules (the `node:` scheme) never match a real `appRoot` prefix either
127
132
  need special-casing.
128
133
 
129
134
  Backtrace parsing is a regex over `Error#stack`, a plain string in V8 rather than a structured
130
- object -- verified directly against real captured stack traces, both synchronous and `async`,
135
+ object: verified directly against real captured stack traces, both synchronous and `async`,
131
136
  named and anonymous frames, before relying on it. One quirk worth knowing: V8 captures an Error's
132
137
  stack at *construction* time, not at `throw` time, so there's no "empty backtrace" case for an
133
138
  exception that's constructed but never thrown.
@@ -135,8 +140,8 @@ exception that's constructed but never thrown.
135
140
  ## PII scrubbing
136
141
 
137
142
  By default, the message, backtrace, and any context/tags you attach are scanned
138
- for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
139
- formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar) --
143
+ for likely personal data: email addresses, formatted SSNs/credit cards, known API key/token
144
+ formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar),
140
145
  and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
141
146
  regardless, so this is a second, earlier layer, not the only one.
142
147
 
@@ -146,6 +151,80 @@ To disable it:
146
151
  forgeOpsTracker.init({ dsn: "...", scrubPii: false });
147
152
  ```
148
153
 
154
+ ## Source context
155
+
156
+ By default, each in-app backtrace frame (never a `node_modules` dependency) is captured along with
157
+ the 5 lines of source on either side of the culprit line, read straight off disk at raise-time, so
158
+ an issue's detail page can show the actual code that broke, not just a `file:line:method`
159
+ reference. This never applies to a frame outside your configured `appRoot`, and it fails silently
160
+ (no context, not a thrown error) for any file that can't be read for whatever reason.
161
+
162
+ This is a real, deliberate exception to "off by default is safer": literal source code is being
163
+ transmitted, not just a reference to it, and the real protection here is not this flag. Every
164
+ project on ForgeOps has its own setting (on by default, off durably and immediately once an org
165
+ owner turns it off, regardless of what any individual app's own `captureSourceContext` is still set
166
+ to) that governs whether the server will ever actually store what a client sends. Set this to
167
+ `false` if you'd rather this client never even attempt the disk read in the first place:
168
+
169
+ ```js
170
+ forgeOpsTracker.init({ dsn: "...", captureSourceContext: false });
171
+ ```
172
+
173
+ ## Session tracking (release health)
174
+
175
+ By default, every request through the Express integration is counted as a session: crash-free
176
+ unless an unhandled exception (or a 5xx response) actually affects it, giving ForgeOps a crash-free
177
+ rate per release to show alongside the errors themselves, not just the errors on their own.
178
+ Counted in-process and flushed as a small periodic aggregate on a `setInterval` timer (never one
179
+ network call per request), the same delivery philosophy as everything else in this client: a broken
180
+ or unreachable tracker never affects the host app either way.
181
+
182
+ ```js
183
+ forgeOpsTracker.init({
184
+ dsn: "...",
185
+ trackSessions: false, // opt out entirely
186
+ sessionFlushIntervalMs: 30000, // default 60000
187
+ });
188
+ ```
189
+
190
+ Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
191
+ flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
192
+
193
+ One deliberate gap: unlike the Ruby gem's `at_exit`, this client does **not** hook `SIGTERM`/
194
+ `SIGINT` to force a final flush on shutdown. Registering a listener for either signal overrides
195
+ Node's own default disposition (the process no longer exits on its own unless something calls
196
+ `process.exit()`), which risks racing (or outright cutting off) a host app's own graceful
197
+ shutdown if this tracker's own listener won that race. The accepted trade-off: up to one
198
+ `sessionFlushIntervalMs` window of session data can be lost on a hard process exit, the same way it
199
+ would be if the interval simply hadn't ticked yet: never a behavior change for whatever app this
200
+ is installed into. See `src/sessionFlusher.js`'s own comment for the full reasoning.
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
+
149
228
  ## Running the tests
150
229
 
151
230
  ```bash
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.1.1",
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",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
9
  "./integrations/express": "./src/integrations/express.js",
10
- "./integrations/fastify": "./src/integrations/fastify.js"
10
+ "./integrations/fastify": "./src/integrations/fastify.js",
11
+ "./integrations/session-tracking": "./src/integrations/sessionTracking.js",
12
+ "./integrations/performance": "./src/integrations/performance.js"
11
13
  },
12
14
  "engines": {
13
15
  "node": ">=18"
package/src/client.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Delivers one payload over HTTP. Every failure mode -- DNS, connection,
3
- * timeout, TLS, a non-2xx response -- is caught here and turned into a
2
+ * Delivers one payload over HTTP. Every failure mode (DNS, connection,
3
+ * timeout, TLS, a non-2xx response) is caught here and turned into a
4
4
  * `false` return rather than a thrown exception, since a broken or
5
5
  * unreachable tracker must never be able to break the host app. Ported
6
6
  * from gems/forge_ops_tracker/lib/forge_ops_tracker/client.rb.
7
7
  *
8
8
  * Uses the global fetch() (stable since Node 18), not a package
9
- * dependency (axios, node-fetch, etc.) -- same reason the Ruby gem uses
9
+ * dependency (axios, node-fetch, etc.): same reason the Ruby gem uses
10
10
  * plain Net::HTTP and the Python/PHP clients use only their own
11
11
  * standard library: this has to work in any host app without adding an
12
12
  * HTTP client dependency of its own.
@@ -20,7 +20,30 @@ export class Client {
20
20
 
21
21
  /** @param {Record<string, unknown>} payload */
22
22
  async deliver(payload) {
23
- const uri = this.#configuration.ingestionUri();
23
+ return this.#post(this.#configuration.ingestionUri(), payload);
24
+ }
25
+
26
+ /** @param {Record<string, unknown>} payload */
27
+ async deliverSessionCheckin(payload) {
28
+ return this.#post(this.#configuration.sessionCheckinsUri(), payload);
29
+ }
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
+
42
+ /**
43
+ * @param {string | null} uri
44
+ * @param {Record<string, unknown>} payload
45
+ */
46
+ async #post(uri, payload) {
24
47
  if (!uri) {
25
48
  return false;
26
49
  }
@@ -2,8 +2,8 @@ import os from "node:os";
2
2
 
3
3
  /**
4
4
  * Holds a single ForgeOps DSN plus everything else the client needs to
5
- * build and deliver events. Mirrors gems/forge_ops_tracker's Configuration
6
- * -- a single Sentry-style DSN string carries both the ingestion URL and
5
+ * build and deliver events. Mirrors gems/forge_ops_tracker's Configuration:
6
+ * a single DSN string carries both the ingestion URL and
7
7
  * the project's api_key: "https://<api_key>@host/api/v1/events".
8
8
  */
9
9
  export class Configuration {
@@ -28,9 +28,43 @@ export class Configuration {
28
28
  timeoutMs = 2000;
29
29
  scrubPii = true;
30
30
 
31
+ /**
32
+ * Whether EventBuilder reads a few lines of source off disk around each
33
+ * in-app frame's culprit line (see EventBuilder#attachSourceContext).
34
+ * Defaults to true so a snippet shows up with no extra setup, but this
35
+ * flag by itself isn't what actually keeps proprietary source code from
36
+ * ending up somewhere it shouldn't: ForgeOps' own per-project setting is
37
+ * the durable, server-enforced off switch, since it applies no matter
38
+ * what this flag happens to be set to on any given deployment, and can't
39
+ * quietly drift back on the way a local config value could. Set this to
40
+ * false too if this host app should never even attempt that disk read in
41
+ * the first place.
42
+ */
43
+ captureSourceContext = true;
44
+
31
45
  /** @type {((message: string) => void) | null} */
32
46
  logger = null;
33
47
 
48
+ /**
49
+ * Whether the Express/Fastify integrations count every request as a session (crash-free
50
+ * unless an unhandled exception actually escaped it) and periodically report an aggregate
51
+ * crash-free rate. On by default, the same "on unless you turn it off" posture error
52
+ * tracking itself already has.
53
+ */
54
+ trackSessions = true;
55
+ /** Milliseconds between aggregate session reports; requests are counted in-process and
56
+ * flushed as one small report on this interval, not one network call per request. */
57
+ sessionFlushIntervalMs = 60000;
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
+
34
68
  /** @returns {string | null} */
35
69
  apiKey() {
36
70
  const parsed = this.#parsedDsn();
@@ -56,6 +90,31 @@ export class Configuration {
56
90
  return stripped.toString();
57
91
  }
58
92
 
93
+ /**
94
+ * Same derivation as ingestionUri, with the trailing /events swapped for /session_checkins:
95
+ * one DSN, two endpoints, matching the Ruby gem's own Configuration#session_checkins_uri.
96
+ * @returns {string | null}
97
+ */
98
+ sessionCheckinsUri() {
99
+ const uri = this.ingestionUri();
100
+ if (!uri) {
101
+ return null;
102
+ }
103
+ return uri.replace(/\/events$/, "/session_checkins");
104
+ }
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
+
59
118
  /** @returns {boolean} */
60
119
  isEnabled() {
61
120
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -73,10 +132,10 @@ export class Configuration {
73
132
  }
74
133
  try {
75
134
  // Unlike Ruby's URI.parse, Python's urlsplit, and PHP's parse_url
76
- // (all lenient, never throwing on malformed input -- verified
135
+ // (all lenient, never throwing on malformed input: verified
77
136
  // directly for each), Node's URL constructor genuinely throws a
78
137
  // TypeError on a malformed string. Verified directly here too,
79
- // not assumed -- hence this try/catch, which the other three
138
+ // not assumed: hence this try/catch, which the other three
80
139
  // clients don't need.
81
140
  return new URL(this.dsn);
82
141
  } catch {
@@ -5,7 +5,7 @@
5
5
  * Ported from gems/forge_ops_tracker/lib/forge_ops_tracker/delivery_queue.rb.
6
6
  *
7
7
  * Not a real background thread the way the Ruby/.NET/Python clients'
8
- * DeliveryQueue is -- Node is single-threaded, but (unlike PHP-FPM) a
8
+ * DeliveryQueue is: Node is single-threaded, but (unlike PHP-FPM) a
9
9
  * Node process is long-running across many requests, so async scheduling
10
10
  * on the event loop is the natural, idiomatic substitute: push() returns
11
11
  * immediately, and delivery happens via non-blocking I/O (fetch) on
@@ -13,7 +13,7 @@
13
13
  * request that pushed it.
14
14
  *
15
15
  * The processing loop is started lazily, on first push, not at
16
- * construction/import time -- mirroring the Ruby/Python clients rather
16
+ * construction/import time: mirroring the Ruby/Python clients rather
17
17
  * than assuming eager start is safe. Node's `cluster` module can fork
18
18
  * worker processes *after* the application (and this module) has
19
19
  * already loaded, the same hazard Puma/Gunicorn have for those clients;
@@ -49,7 +49,7 @@ export class DeliveryQueue {
49
49
  return;
50
50
  }
51
51
  this.#processing = true;
52
- // Deliberately not awaited -- this is the background loop itself;
52
+ // Deliberately not awaited: this is the background loop itself;
53
53
  // push() must return synchronously regardless of how long delivery
54
54
  // takes.
55
55
  this.#processLoop();
@@ -1,8 +1,23 @@
1
+ import fs from "node:fs";
1
2
  import { fileURLToPath } from "node:url";
2
3
  import { scrub, scrubString } from "./piiScrubber.js";
3
4
 
4
5
  const MAX_FRAMES = 500;
5
6
 
7
+ // How many lines of source to grab on either side of the culprit line (see
8
+ // #attachSourceContext), and the longest a single captured line is allowed
9
+ // to be before getting truncated: guards against one pathological
10
+ // minified/generated line ballooning the payload. ForgeOps itself
11
+ // re-truncates on arrival too, the same "don't just trust the SDK" posture
12
+ // MAX_FRAMES already gets on the server side.
13
+ const CONTEXT_LINES = 5;
14
+ const MAX_CONTEXT_LINE_LENGTH = 500;
15
+
16
+ // Identifies this client to the server's auto language-detection on the project the event lands
17
+ // in (see Project#note_sdk_platform server-side); matches this repo's own sdks/node directory
18
+ // name, the same convention every other language's client follows.
19
+ const SDK_NAME = "node";
20
+
6
21
  // Parses a single V8 stack trace line, e.g.
7
22
  // " at innerFunc (file:///app/src/foo.js:12:9)"
8
23
  // " at file:///app/src/foo.js:8:3" (no named function)
@@ -15,7 +30,7 @@ const FRAME_RE = /^\s*at\s+(?:(async)\s+)?(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/;
15
30
  /**
16
31
  * Turns a raised exception into the payload shape the ingestion API
17
32
  * expects. Ported from
18
- * gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb --
33
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb:
19
34
  * backtrace parsing here is a regex over V8's Error#stack string, the
20
35
  * same general approach as the Ruby gem's own regex over MRI backtrace
21
36
  * lines (Node doesn't expose a structured frame list the way Python's
@@ -43,29 +58,40 @@ export class EventBuilder {
43
58
  server_name: this.#configuration.serverName,
44
59
  context: { ...context },
45
60
  tags: {},
61
+ sdk_name: SDK_NAME,
46
62
  };
47
63
 
48
64
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
49
65
  }
50
66
 
51
67
  // exception_class/occurred_at/environment/release/server_name are left
52
- // alone -- structured fields this client or the host app sets
68
+ // alone: structured fields this client or the host app sets
53
69
  // deliberately, not free text an exception or its context could
54
70
  // accidentally spill sensitive data into.
55
71
  #scrub(payload) {
56
72
  return {
57
73
  ...payload,
58
74
  message: scrubString(payload.message),
59
- backtrace: payload.backtrace.map((frame) => ({
60
- ...frame,
61
- file: frame.file ? scrubString(frame.file) : frame.file,
62
- method: frame.method ? scrubString(frame.method) : frame.method,
63
- })),
75
+ backtrace: payload.backtrace.map((frame) => this.#scrubFrame(frame)),
64
76
  context: scrub(payload.context),
65
77
  tags: scrub(payload.tags),
66
78
  };
67
79
  }
68
80
 
81
+ #scrubFrame(frame) {
82
+ const scrubbed = {
83
+ ...frame,
84
+ file: frame.file ? scrubString(frame.file) : frame.file,
85
+ method: frame.method ? scrubString(frame.method) : frame.method,
86
+ };
87
+ if ("context_line" in scrubbed) {
88
+ scrubbed.context_line = scrubString(scrubbed.context_line);
89
+ scrubbed.pre_context = scrubbed.pre_context.map(scrubString);
90
+ scrubbed.post_context = scrubbed.post_context.map(scrubString);
91
+ }
92
+ return scrubbed;
93
+ }
94
+
69
95
  #backtrace(error) {
70
96
  const stack = typeof error?.stack === "string" ? error.stack : "";
71
97
  const frames = [];
@@ -82,12 +108,14 @@ export class EventBuilder {
82
108
  const [, , fn, rawFile, lineNo, ] = match;
83
109
  const file = normalizeFile(rawFile);
84
110
 
85
- frames.push({
86
- file,
87
- line: Number(lineNo),
88
- method: fn ?? null,
89
- in_app: this.#isInApp(file),
90
- });
111
+ frames.push(
112
+ this.#attachSourceContext({
113
+ file,
114
+ line: Number(lineNo),
115
+ method: fn ?? null,
116
+ in_app: this.#isInApp(file),
117
+ }),
118
+ );
91
119
  }
92
120
 
93
121
  return frames;
@@ -103,10 +131,69 @@ export class EventBuilder {
103
131
  }
104
132
  return !file.includes("/node_modules/");
105
133
  }
134
+
135
+ // Reads a few lines of source straight off disk around the culprit line,
136
+ // at raise-time, in the same running process the exception came from.
137
+ // Gated on two things: the frame has to be in-app (never a
138
+ // node_modules/vendored dependency: there'd be nothing meaningful to
139
+ // show, and it's not the host app's own code to begin with), and
140
+ // configuration.captureSourceContext has to be true (see Configuration
141
+ // for why it defaults to true and why ForgeOps' own per-project setting,
142
+ // not this flag, is the durable, protected way to turn it off).
143
+ // Best-effort: any file that can't be read (deleted, permission denied, a
144
+ // path that only ever existed inside a build step and isn't present in
145
+ // this deployment) just means this one frame gets no source context,
146
+ // never a thrown error of its own.
147
+ #attachSourceContext(frame) {
148
+ if (!this.#configuration.captureSourceContext || !frame.in_app) {
149
+ return frame;
150
+ }
151
+
152
+ let lines;
153
+ try {
154
+ lines = splitLines(fs.readFileSync(frame.file, "utf8"));
155
+ } catch {
156
+ return frame;
157
+ }
158
+
159
+ const index = frame.line - 1;
160
+ if (index < 0 || index >= lines.length) {
161
+ return frame;
162
+ }
163
+
164
+ const from = Math.max(index - CONTEXT_LINES, 0);
165
+ const to = Math.min(index + CONTEXT_LINES, lines.length - 1);
166
+
167
+ return {
168
+ ...frame,
169
+ context_line: truncateLine(lines[index]),
170
+ pre_context: lines.slice(from, index).map(truncateLine),
171
+ post_context: lines.slice(index + 1, to + 1).map(truncateLine),
172
+ };
173
+ }
174
+ }
175
+
176
+ // Splits file content into lines the same way Ruby's File.readlines and
177
+ // Python's readlines() do: no phantom empty final element when the file
178
+ // ends with a trailing newline (a plain `content.split("\n")` would add
179
+ // one), and no trailing "\r" left over from a CRLF line ending.
180
+ function splitLines(content) {
181
+ const lines = content.split("\n");
182
+ if (content.endsWith("\n")) {
183
+ lines.pop();
184
+ }
185
+ return lines.map((line) => line.replace(/\r$/, ""));
186
+ }
187
+
188
+ function truncateLine(line) {
189
+ if (line.length <= MAX_CONTEXT_LINE_LENGTH) {
190
+ return line;
191
+ }
192
+ return `${line.slice(0, MAX_CONTEXT_LINE_LENGTH)}...`;
106
193
  }
107
194
 
108
195
  // V8 stack frames use file:// URLs under ESM, but plain paths under
109
- // CommonJS -- normalized to a plain path either way so it's comparable
196
+ // CommonJS: normalized to a plain path either way so it's comparable
110
197
  // against Configuration#appRoot (always a plain path, from process.cwd()).
111
198
  // Node's own internal modules (the "node:" scheme) are left as-is; they
112
199
  // never match a real appRoot prefix anyway, so they're correctly never
package/src/index.js CHANGED
@@ -2,12 +2,16 @@ 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";
7
+ import { SessionFlusher } from "./sessionFlusher.js";
6
8
 
7
9
  export { Configuration };
8
10
 
9
11
  let configuration = null;
10
12
  let reporter = null;
13
+ let sessionFlusher = null;
14
+ let performanceFlusher = null;
11
15
  let processHandlersInstalled = false;
12
16
  let uncaughtExceptionListener = null;
13
17
  let unhandledRejectionListener = null;
@@ -29,6 +33,52 @@ function getReporter() {
29
33
  return reporter;
30
34
  }
31
35
 
36
+ function getSessionFlusher() {
37
+ if (sessionFlusher === null) {
38
+ const config = getConfiguration();
39
+ sessionFlusher = new SessionFlusher(config, new Client(config));
40
+ }
41
+ return sessionFlusher;
42
+ }
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
+
52
+ /**
53
+ * Internal; called by the Express/Fastify session-tracking integrations, never by host app
54
+ * code directly (there's nothing for a caller to decide here beyond what the middleware/hook
55
+ * itself already observed).
56
+ *
57
+ * @param {boolean} crashed
58
+ */
59
+ export function _recordSession(crashed) {
60
+ const config = getConfiguration();
61
+ if (!config.trackSessions || !config.isEnabled()) {
62
+ return;
63
+ }
64
+ getSessionFlusher().recordSession(crashed);
65
+ }
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
+
32
82
  /**
33
83
  * Configure the client. Call once at startup.
34
84
  *
@@ -65,22 +115,22 @@ export function captureException(error, context = {}) {
65
115
 
66
116
  /**
67
117
  * Reports anything that would otherwise crash the process outright (a
68
- * plain script, an unhandled promise rejection) with no further wiring --
118
+ * plain script, an unhandled promise rejection) with no further wiring:
69
119
  * the same "unhandled needs no wiring" case the Express/Fastify
70
120
  * integrations cover for web requests.
71
121
  *
72
122
  * Deliberately listens on "uncaughtExceptionMonitor", not
73
- * "uncaughtException" -- registering any listener for the latter
123
+ * "uncaughtException": registering any listener for the latter
74
124
  * *suppresses Node's own default crash behavior entirely* (the process
75
125
  * would no longer exit on its own; official Node docs are explicit that
76
126
  * resuming normal operation after an uncaught exception isn't safe).
77
127
  * "uncaughtExceptionMonitor" exists specifically for observability tools
78
128
  * like this one: it fires without changing what Node does afterward,
79
- * confirmed directly (not assumed) against a real uncaught throw --
129
+ * confirmed directly (not assumed) against a real uncaught throw:
80
130
  * Node still printed its usual crash output and exited with code 1.
81
131
  *
82
132
  * This does *not* catch a web request's unhandled exception under
83
- * Express/Fastify -- both catch that themselves, long before it would
133
+ * Express/Fastify: both catch that themselves, long before it would
84
134
  * ever reach here, which is what those integrations are for.
85
135
  */
86
136
  function installProcessLevelHandlers() {
@@ -101,7 +151,7 @@ function installProcessLevelHandlers() {
101
151
  process.on("unhandledRejection", unhandledRejectionListener);
102
152
  }
103
153
 
104
- /** @internal not part of the public API -- resets module state between test cases */
154
+ /** @internal not part of the public API: resets module state between test cases */
105
155
  export function _resetForTesting() {
106
156
  if (uncaughtExceptionListener) {
107
157
  process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
@@ -111,6 +161,8 @@ export function _resetForTesting() {
111
161
  }
112
162
  configuration = null;
113
163
  reporter = null;
164
+ sessionFlusher = null;
165
+ performanceFlusher = null;
114
166
  processHandlersInstalled = false;
115
167
  uncaughtExceptionListener = null;
116
168
  unhandledRejectionListener = null;
@@ -9,11 +9,17 @@ import { captureException } from "../index.js";
9
9
  * custom error handler registered after this one, or its default 500
10
10
  * response) continues exactly as if this middleware weren't there.
11
11
  * Express 5 forwards both synchronous throws and rejected promises from
12
- * async route handlers to error-handling middleware automatically --
12
+ * async route handlers to error-handling middleware automatically,
13
13
  * verified directly against a real async handler, not assumed. Only an
14
14
  * exception your own code catches and handles is invisible to this.
15
15
  */
16
16
  export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
17
+ // See integrations/sessionTracking.js's own comment: marks the request crashed before
18
+ // anything else, so its res.on("finish") listener (which fires later, once this or
19
+ // whatever error handling runs after it actually sends a response) already sees this by
20
+ // the time it checks.
21
+ req._forgeOpsSessionCrashed = true;
22
+
17
23
  captureException(err, {
18
24
  path: req.path,
19
25
  method: req.method,
@@ -1,14 +1,14 @@
1
1
  import { captureException } from "../index.js";
2
2
 
3
3
  /**
4
- * Called directly with your Fastify instance -- not registered via
4
+ * Called directly with your Fastify instance: not registered via
5
5
  * app.register(), which would create a new encapsulation scope and
6
6
  * require the fastify-plugin package to break out of it:
7
7
  *
8
8
  * registerForgeOpsTracker(app);
9
9
  *
10
10
  * Adds an onError hook, which fires for any error Fastify's own
11
- * lifecycle would otherwise handle -- both synchronous throws and
11
+ * lifecycle would otherwise handle: both synchronous throws and
12
12
  * rejected promises in async handlers, verified directly against a real
13
13
  * async handler, not assumed. Doesn't set the reply itself, so Fastify's
14
14
  * own error handling (setErrorHandler, or its default response)
@@ -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,32 @@
1
+ import { _recordSession } from "../index.js";
2
+
3
+ /**
4
+ * Express session-tracking middleware. Register FIRST, before any routes:
5
+ *
6
+ * app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
7
+ * // ...routes...
8
+ * app.use(forgeOpsTrackerExpressMiddleware); // still last, per its own doc comment
9
+ *
10
+ * Counts every request as a session (crash-free unless an unhandled exception actually
11
+ * affected it) toward the crash-free rate on a project's Releases page. A separate,
12
+ * independent middleware from forgeOpsTrackerExpressMiddleware, not layered onto it, since
13
+ * session tracking runs regardless of whether error reporting is even configured, mirroring
14
+ * gems/forge_ops_tracker's own split between its Rack middleware (sessions) and its
15
+ * Rails.error hook (errors), two genuinely independent mechanisms.
16
+ *
17
+ * Express has no single call that wraps a whole request/response cycle the way Rack's
18
+ * app.call(env) does, so "crashed" is read from two signals rather than one: the response's
19
+ * own final status code (>= 500, Express's own default for an unhandled error even with no
20
+ * error-handling middleware installed at all), and a request-scoped flag that
21
+ * forgeOpsTrackerExpressMiddleware sets before it does anything else, for the case a host app
22
+ * has its own custom error handler that responds with something other than a plain 500. Either
23
+ * signal alone would work when both middlewares are installed; checking both means session
24
+ * tracking still works correctly even when the error-reporting middleware isn't.
25
+ */
26
+ export function forgeOpsTrackerSessionTrackingExpressMiddleware(req, res, next) {
27
+ req._forgeOpsSessionCrashed = false;
28
+ res.on("finish", () => {
29
+ _recordSession(req._forgeOpsSessionCrashed || res.statusCode >= 500);
30
+ });
31
+ next();
32
+ }
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Redacts likely-sensitive content out of a payload before it ever leaves
3
- * this process -- the same patterns ForgeOps itself applies again on
3
+ * this process: the same patterns ForgeOps itself applies again on
4
4
  * arrival (defense in depth: this layer keeps the data off the wire and
5
5
  * out of any request logging in between; the server-side layer is what
6
6
  * actually protects the database, and doesn't depend on every reporting
7
7
  * app running an up-to-date version of this client). Ported from
8
- * gems/forge_ops_tracker/lib/forge_ops_tracker/pii_scrubber.rb -- kept
8
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/pii_scrubber.rb: kept
9
9
  * standalone and dependency-free here for the same reason as the Ruby
10
10
  * original: this has to work in any host app regardless of what's
11
11
  * reporting into it.
package/src/reporter.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Ties Configuration, EventBuilder, and DeliveryQueue together into the
3
3
  * one thing callers actually need: report an exception. Mirrors
4
- * gems/forge_ops_tracker's ErrorSubscriber#report -- never throws. An
4
+ * gems/forge_ops_tracker's ErrorSubscriber#report: never throws. An
5
5
  * error reporter that itself throws while reporting an error is the
6
6
  * worst possible failure mode, so every path here is wrapped to
7
7
  * guarantee this never propagates back into the host app.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Counts requests and crashes in-process (see the Express/Fastify session-tracking
3
+ * integrations) and periodically flushes the totals as one small aggregate report, rather
4
+ * than one network call per request. Ported from
5
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/session_flusher.rb.
6
+ *
7
+ * setInterval(...).unref(), not a real background thread; Node is single-threaded, and
8
+ * unref() specifically so an idle flush timer never holds a short script's process open on
9
+ * its own, the same "never affect the host app just by being configured" principle
10
+ * DeliveryQueue's own lazy start already follows.
11
+ *
12
+ * Deliberately does NOT hook SIGTERM/SIGINT to force a final flush the way Ruby's at_exit
13
+ * does. Considered and rejected: registering a listener for either signal overrides Node's
14
+ * own default disposition (the process no longer exits on its own; something has to call
15
+ * process.exit() explicitly), which risks racing with, or outright cutting off, a host app's
16
+ * own graceful shutdown (draining in-flight requests, closing a database pool) if this
17
+ * tracker's own listener calls exit() first. That's a real correctness risk to the host app
18
+ * itself, not just a data-completeness one, and this SDK's whole design already treats "never
19
+ * affect the host app" as non-negotiable everywhere else (Client#deliver never throws,
20
+ * DeliveryQueue drops rather than blocks when full). The accepted trade-off instead: up to one
21
+ * sessionFlushIntervalMs window of session data can be lost on a hard process exit, the same
22
+ * way it would be if the interval simply hadn't ticked yet: a bounded, honest gap, not a
23
+ * behavior change for whatever app this is installed into.
24
+ */
25
+ export class SessionFlusher {
26
+ #configuration;
27
+ #client;
28
+ #sessionsCount = 0;
29
+ #crashedSessionsCount = 0;
30
+ #periodStartedAt = new Date();
31
+ #timer = null;
32
+
33
+ constructor(configuration, client) {
34
+ this.#configuration = configuration;
35
+ this.#client = client;
36
+ }
37
+
38
+ /** @param {boolean} crashed */
39
+ recordSession(crashed) {
40
+ this.#ensureTimerStarted();
41
+ this.#sessionsCount += 1;
42
+ if (crashed) {
43
+ this.#crashedSessionsCount += 1;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Snapshots and resets the in-process counters, then delivers them. A failed delivery keeps
49
+ * the counts where they are rather than resetting, so the next flush's window just grows
50
+ * instead of losing what was already tallied; there's no other copy of this data anywhere.
51
+ */
52
+ async flush() {
53
+ if (this.#sessionsCount === 0) {
54
+ return;
55
+ }
56
+
57
+ const snapshot = {
58
+ release: this.#configuration.release,
59
+ environment: this.#configuration.environment,
60
+ period_started_at: this.#periodStartedAt.toISOString(),
61
+ period_ended_at: new Date().toISOString(),
62
+ sessions_count: this.#sessionsCount,
63
+ crashed_sessions_count: this.#crashedSessionsCount,
64
+ };
65
+
66
+ const delivered = await this.#client.deliverSessionCheckin(snapshot);
67
+ if (!delivered) {
68
+ return;
69
+ }
70
+
71
+ this.#sessionsCount = 0;
72
+ this.#crashedSessionsCount = 0;
73
+ this.#periodStartedAt = new Date();
74
+ }
75
+
76
+ #ensureTimerStarted() {
77
+ if (this.#timer !== null) {
78
+ return;
79
+ }
80
+
81
+ this.#timer = setInterval(() => {
82
+ this.flush().catch((e) => {
83
+ // Per-tick, not left to reject silently: one bad flush must not kill every flush
84
+ // after it (setInterval keeps calling this callback regardless either way, but a
85
+ // silently swallowed rejection would still be worth logging).
86
+ this.#configuration.log(`[forge-ops-tracker] session flush error: ${e.name}: ${e.message}`);
87
+ });
88
+ }, this.#configuration.sessionFlushIntervalMs);
89
+ this.#timer.unref();
90
+ }
91
+ }