@forge-ops/tracker 0.1.0 → 0.2.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.
4
- Requires Node 18+ (for global `fetch`). A from-scratch port of
5
- [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker) (the Rails client) -- see that gem's
6
- README for the shared design rationale; this document only covers what's Node-specific.
3
+ Node.js error reporting client for a [ForgeOps](../../) instance.
4
+ Requires Node 18+ (for global `fetch`). It captures uncaught exceptions, unhandled promise
5
+ rejections, and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers
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,15 @@ 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";
38
39
  import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
39
40
 
40
- app.use(forgeOpsTrackerExpressMiddleware); // last, after all routes
41
+ app.use(forgeOpsTrackerSessionTrackingExpressMiddleware); // first, before any routes
42
+ // ...routes...
43
+ app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
41
44
  ```
42
45
 
43
- Requires Express 5+ -- its automatic forwarding of both synchronous throws *and* rejected promises
46
+ Requires Express 5+: its automatic forwarding of both synchronous throws *and* rejected promises
44
47
  from `async` route handlers to error-handling middleware is what makes this work with zero other
45
48
  wiring (verified directly against a real async handler, not assumed). Express 4 does not do this
46
49
  for async handlers; each route would need its own try/catch there instead.
@@ -54,7 +57,7 @@ registerForgeOpsTracker(app); // called directly, not via app.register()
54
57
  ```
55
58
 
56
59
  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
60
+ a new encapsulation scope (and require the `fastify-plugin` package to break out of it): this
58
61
  avoids that entirely.
59
62
 
60
63
  ## What gets reported automatically, and what doesn't
@@ -63,7 +66,7 @@ avoids that entirely.
63
66
  report anything that propagates uncaught out of a route handler, then let the framework handle it
64
67
  exactly as if this client weren't installed.
65
68
 
66
- **An exception your own code catches and handles is different -- neither integration ever sees
69
+ **An exception your own code catches and handles is different: neither integration ever sees
67
70
  it**, since it never propagates far enough to reach either hook:
68
71
 
69
72
  ```js
@@ -71,13 +74,13 @@ try {
71
74
  await chargeCard(order);
72
75
  } catch (err) {
73
76
  logger.warn(`card declined: ${err.message}`);
74
- // ForgeOps never sees this -- caught locally, never reaches the
77
+ // ForgeOps never sees this: caught locally, never reaches the
75
78
  // middleware/hook at all.
76
79
  }
77
80
  ```
78
81
 
79
- There's no Express/Fastify-wide equivalent to Rails' `Rails.error.handle` here -- report it
80
- explicitly instead, right at the catch site:
82
+ There's no application-wide hook that reports an exception while still letting your own catch
83
+ block handle it: report it explicitly instead, right at the catch site:
81
84
 
82
85
  ```js
83
86
  } catch (err) {
@@ -91,54 +94,52 @@ explicitly instead, right at the catch site:
91
94
  `init()` also installs `process` event listeners by default (`installProcessHandlers: false` to
92
95
  opt out) covering two cases with no wiring needed:
93
96
 
94
- - **`uncaughtExceptionMonitor`**, not `uncaughtException` -- deliberately. Registering any listener
97
+ - **`uncaughtExceptionMonitor`**, not `uncaughtException`: deliberately. Registering any listener
95
98
  for `uncaughtException` *suppresses Node's own default crash behavior entirely* (the process
96
99
  would no longer exit on its own; Node's docs are explicit that resuming normal operation
97
100
  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
101
+ like this one: it fires without changing what Node does afterward, verified directly against a
99
102
  real uncaught throw, not assumed.
100
- - **`unhandledRejection`** -- a rejected promise nobody awaited or attached a `.catch()` to,
103
+ - **`unhandledRejection`**: a rejected promise nobody awaited or attached a `.catch()` to,
101
104
  arguably the most common way a modern async Node app silently fails.
102
105
 
103
- Neither catches a web request's unhandled exception under Express/Fastify -- both catch that
106
+ Neither catches a web request's unhandled exception under Express/Fastify: both catch that
104
107
  themselves, long before it would ever reach here.
105
108
 
106
- Every failure mode -- network errors, timeouts, a full queue, a malformed DSN -- is caught and
109
+ Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
107
110
  dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
108
111
 
109
112
  ## Delivery: an async loop, not a thread
110
113
 
111
- `DeliveryQueue` here isn't a background *thread* the way the Ruby/.NET/Python clients' equivalent
112
- class is -- Node is single-threaded. But (unlike PHP-FPM) a Node process is long-running across many
113
- requests, so an async processing loop on the event loop is the natural substitute: `push()` returns
114
- immediately, and delivery happens via non-blocking `fetch()` calls without ever blocking the
115
- request that pushed it. The loop starts lazily, on first push, not at import time -- Node's
116
- `cluster` module can fork worker processes *after* the application has already loaded, the same
117
- hazard Puma/Gunicorn have for the Ruby/Python clients; starting fresh on first push means each
118
- forked worker gets its own live loop regardless of when it was forked relative to import.
114
+ `DeliveryQueue` here isn't a background *thread*: Node is single-threaded. But a Node process is
115
+ long-running across many requests, so an async processing loop on the event loop is the natural
116
+ substitute: `push()` returns immediately, and delivery happens via non-blocking `fetch()` calls
117
+ without ever blocking the request that pushed it. The loop starts lazily, on first push, not at
118
+ import time: Node's `cluster` module can fork worker processes *after* the application has
119
+ already loaded, and an eagerly-started loop would be left dead in every forked child; starting
120
+ fresh on first push means each forked worker gets its own live loop regardless of when it was
121
+ forked relative to import.
119
122
 
120
123
  ## `in_app` backtrace frames
121
124
 
122
- Like the Ruby gem (and unlike the .NET SDK, where a compiled assembly's file path never matches its
123
- original source location), Node runs interpreted directly from real `.js` files on disk, so
124
- file-path matching against `Configuration#appRoot` works the same way it does in the Ruby gem's
125
- `Rails.root` comparison. Defaults to the current working directory; set it explicitly if that
126
- doesn't match your app's actual layout. `node_modules` frames are never marked `in_app`, regardless
127
- of `appRoot`; Node's own internal modules (the `node:` scheme) never match a real `appRoot` prefix
128
- either, so they don't need special-casing.
129
-
130
- Backtrace parsing is a regex over `Error#stack` (a plain string in V8, unlike Python's structured
131
- `traceback` module or PHP's `Throwable::getTrace()`) -- verified directly against real captured
132
- stack traces, both synchronous and `async`, named and anonymous frames, before relying on it. A
133
- related quirk, shared with PHP but not Ruby/.NET/Python: V8 captures an Error's stack at
134
- *construction* time, not at `throw` time, so there's no "empty backtrace" case for an exception
135
- that's constructed but never thrown.
125
+ Node runs interpreted directly from real `.js` files on disk, so file-path matching against
126
+ `Configuration#appRoot` is a straightforward prefix comparison against those on-disk paths.
127
+ Defaults to the current working directory; set it explicitly if that doesn't match your app's
128
+ actual layout. `node_modules` frames are never marked `in_app`, regardless of `appRoot`; Node's own
129
+ internal modules (the `node:` scheme) never match a real `appRoot` prefix either, so they don't
130
+ need special-casing.
131
+
132
+ Backtrace parsing is a regex over `Error#stack`, a plain string in V8 rather than a structured
133
+ object: verified directly against real captured stack traces, both synchronous and `async`,
134
+ named and anonymous frames, before relying on it. One quirk worth knowing: V8 captures an Error's
135
+ stack at *construction* time, not at `throw` time, so there's no "empty backtrace" case for an
136
+ exception that's constructed but never thrown.
136
137
 
137
138
  ## PII scrubbing
138
139
 
139
- Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned
140
- for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
141
- formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar) --
140
+ By default, the message, backtrace, and any context/tags you attach are scanned
141
+ for likely personal data: email addresses, formatted SSNs/credit cards, known API key/token
142
+ formats, and anything under a suspiciously-named key (`password`, `apiKey`, `ssn`, and similar),
142
143
  and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
143
144
  regardless, so this is a second, earlier layer, not the only one.
144
145
 
@@ -148,6 +149,54 @@ To disable it:
148
149
  forgeOpsTracker.init({ dsn: "...", scrubPii: false });
149
150
  ```
150
151
 
152
+ ## Source context
153
+
154
+ By default, each in-app backtrace frame (never a `node_modules` dependency) is captured along with
155
+ the 5 lines of source on either side of the culprit line, read straight off disk at raise-time, so
156
+ an issue's detail page can show the actual code that broke, not just a `file:line:method`
157
+ reference. This never applies to a frame outside your configured `appRoot`, and it fails silently
158
+ (no context, not a thrown error) for any file that can't be read for whatever reason.
159
+
160
+ This is a real, deliberate exception to "off by default is safer": literal source code is being
161
+ transmitted, not just a reference to it, and the real protection here is not this flag. Every
162
+ project on ForgeOps has its own setting (on by default, off durably and immediately once an org
163
+ owner turns it off, regardless of what any individual app's own `captureSourceContext` is still set
164
+ to) that governs whether the server will ever actually store what a client sends. Set this to
165
+ `false` if you'd rather this client never even attempt the disk read in the first place:
166
+
167
+ ```js
168
+ forgeOpsTracker.init({ dsn: "...", captureSourceContext: false });
169
+ ```
170
+
171
+ ## Session tracking (release health)
172
+
173
+ By default, every request through the Express integration is counted as a session: crash-free
174
+ unless an unhandled exception (or a 5xx response) actually affects it, giving ForgeOps a crash-free
175
+ rate per release to show alongside the errors themselves, not just the errors on their own.
176
+ Counted in-process and flushed as a small periodic aggregate on a `setInterval` timer (never one
177
+ network call per request), the same delivery philosophy as everything else in this client: a broken
178
+ or unreachable tracker never affects the host app either way.
179
+
180
+ ```js
181
+ forgeOpsTracker.init({
182
+ dsn: "...",
183
+ trackSessions: false, // opt out entirely
184
+ sessionFlushIntervalMs: 30000, // default 60000
185
+ });
186
+ ```
187
+
188
+ Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
189
+ flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
190
+
191
+ One deliberate gap: unlike the Ruby gem's `at_exit`, this client does **not** hook `SIGTERM`/
192
+ `SIGINT` to force a final flush on shutdown. Registering a listener for either signal overrides
193
+ Node's own default disposition (the process no longer exits on its own unless something calls
194
+ `process.exit()`), which risks racing (or outright cutting off) a host app's own graceful
195
+ shutdown if this tracker's own listener won that race. The accepted trade-off: up to one
196
+ `sessionFlushIntervalMs` window of session data can be lost on a hard process exit, the same way it
197
+ would be if the interval simply hadn't ticked yet: never a behavior change for whatever app this
198
+ is installed into. See `src/sessionFlusher.js`'s own comment for the full reasoning.
199
+
151
200
  ## Running the tests
152
201
 
153
202
  ```bash
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.1.0",
3
+ "version": "0.2.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"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=18"
@@ -31,8 +32,12 @@
31
32
  "fastify": ">=4"
32
33
  },
33
34
  "peerDependenciesMeta": {
34
- "express": { "optional": true },
35
- "fastify": { "optional": true }
35
+ "express": {
36
+ "optional": true
37
+ },
38
+ "fastify": {
39
+ "optional": true
40
+ }
36
41
  },
37
42
  "devDependencies": {
38
43
  "@eslint/js": "^10.0.0",
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,19 @@ 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
+ * @param {string | null} uri
33
+ * @param {Record<string, unknown>} payload
34
+ */
35
+ async #post(uri, payload) {
24
36
  if (!uri) {
25
37
  return false;
26
38
  }
@@ -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,34 @@ 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
+
34
59
  /** @returns {string | null} */
35
60
  apiKey() {
36
61
  const parsed = this.#parsedDsn();
@@ -56,6 +81,19 @@ export class Configuration {
56
81
  return stripped.toString();
57
82
  }
58
83
 
84
+ /**
85
+ * Same derivation as ingestionUri, with the trailing /events swapped for /session_checkins:
86
+ * one DSN, two endpoints, matching the Ruby gem's own Configuration#session_checkins_uri.
87
+ * @returns {string | null}
88
+ */
89
+ sessionCheckinsUri() {
90
+ const uri = this.ingestionUri();
91
+ if (!uri) {
92
+ return null;
93
+ }
94
+ return uri.replace(/\/events$/, "/session_checkins");
95
+ }
96
+
59
97
  /** @returns {boolean} */
60
98
  isEnabled() {
61
99
  return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
@@ -73,10 +111,10 @@ export class Configuration {
73
111
  }
74
112
  try {
75
113
  // Unlike Ruby's URI.parse, Python's urlsplit, and PHP's parse_url
76
- // (all lenient, never throwing on malformed input -- verified
114
+ // (all lenient, never throwing on malformed input: verified
77
115
  // directly for each), Node's URL constructor genuinely throws a
78
116
  // TypeError on a malformed string. Verified directly here too,
79
- // not assumed -- hence this try/catch, which the other three
117
+ // not assumed: hence this try/catch, which the other three
80
118
  // clients don't need.
81
119
  return new URL(this.dsn);
82
120
  } 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
@@ -3,11 +3,13 @@ import { Configuration } from "./configuration.js";
3
3
  import { DeliveryQueue } from "./deliveryQueue.js";
4
4
  import { EventBuilder } from "./eventBuilder.js";
5
5
  import { Reporter } from "./reporter.js";
6
+ import { SessionFlusher } from "./sessionFlusher.js";
6
7
 
7
8
  export { Configuration };
8
9
 
9
10
  let configuration = null;
10
11
  let reporter = null;
12
+ let sessionFlusher = null;
11
13
  let processHandlersInstalled = false;
12
14
  let uncaughtExceptionListener = null;
13
15
  let unhandledRejectionListener = null;
@@ -29,6 +31,29 @@ function getReporter() {
29
31
  return reporter;
30
32
  }
31
33
 
34
+ function getSessionFlusher() {
35
+ if (sessionFlusher === null) {
36
+ const config = getConfiguration();
37
+ sessionFlusher = new SessionFlusher(config, new Client(config));
38
+ }
39
+ return sessionFlusher;
40
+ }
41
+
42
+ /**
43
+ * Internal; called by the Express/Fastify session-tracking integrations, never by host app
44
+ * code directly (there's nothing for a caller to decide here beyond what the middleware/hook
45
+ * itself already observed).
46
+ *
47
+ * @param {boolean} crashed
48
+ */
49
+ export function _recordSession(crashed) {
50
+ const config = getConfiguration();
51
+ if (!config.trackSessions || !config.isEnabled()) {
52
+ return;
53
+ }
54
+ getSessionFlusher().recordSession(crashed);
55
+ }
56
+
32
57
  /**
33
58
  * Configure the client. Call once at startup.
34
59
  *
@@ -65,22 +90,22 @@ export function captureException(error, context = {}) {
65
90
 
66
91
  /**
67
92
  * Reports anything that would otherwise crash the process outright (a
68
- * plain script, an unhandled promise rejection) with no further wiring --
93
+ * plain script, an unhandled promise rejection) with no further wiring:
69
94
  * the same "unhandled needs no wiring" case the Express/Fastify
70
95
  * integrations cover for web requests.
71
96
  *
72
97
  * Deliberately listens on "uncaughtExceptionMonitor", not
73
- * "uncaughtException" -- registering any listener for the latter
98
+ * "uncaughtException": registering any listener for the latter
74
99
  * *suppresses Node's own default crash behavior entirely* (the process
75
100
  * would no longer exit on its own; official Node docs are explicit that
76
101
  * resuming normal operation after an uncaught exception isn't safe).
77
102
  * "uncaughtExceptionMonitor" exists specifically for observability tools
78
103
  * like this one: it fires without changing what Node does afterward,
79
- * confirmed directly (not assumed) against a real uncaught throw --
104
+ * confirmed directly (not assumed) against a real uncaught throw:
80
105
  * Node still printed its usual crash output and exited with code 1.
81
106
  *
82
107
  * This does *not* catch a web request's unhandled exception under
83
- * Express/Fastify -- both catch that themselves, long before it would
108
+ * Express/Fastify: both catch that themselves, long before it would
84
109
  * ever reach here, which is what those integrations are for.
85
110
  */
86
111
  function installProcessLevelHandlers() {
@@ -101,7 +126,7 @@ function installProcessLevelHandlers() {
101
126
  process.on("unhandledRejection", unhandledRejectionListener);
102
127
  }
103
128
 
104
- /** @internal not part of the public API -- resets module state between test cases */
129
+ /** @internal not part of the public API: resets module state between test cases */
105
130
  export function _resetForTesting() {
106
131
  if (uncaughtExceptionListener) {
107
132
  process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
@@ -111,6 +136,7 @@ export function _resetForTesting() {
111
136
  }
112
137
  configuration = null;
113
138
  reporter = null;
139
+ sessionFlusher = null;
114
140
  processHandlersInstalled = false;
115
141
  uncaughtExceptionListener = null;
116
142
  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,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
+ }
@@ -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
+ }