@forge-ops/tracker 0.1.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/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeOps
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @forge-ops/tracker
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.
7
+
8
+ ## Installation
9
+
10
+ Not yet published to npm -- install directly from this path (or a local checkout, once split into
11
+ its own repo):
12
+
13
+ ```bash
14
+ npm install /path/to/forge_ops/sdks/node
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
20
+ variable or explicitly:
21
+
22
+ ```js
23
+ import * as forgeOpsTracker from "@forge-ops/tracker";
24
+
25
+ forgeOpsTracker.init({
26
+ dsn: "https://<api_key>@your-forgeops-host/api/v1/events", // or leave unset to read FORGE_OPS_DSN
27
+ release: "...",
28
+ environment: "production",
29
+ });
30
+ ```
31
+
32
+ Call `init()` once at startup, before handling any requests. Any `Configuration` property can be
33
+ overridden by passing it in the options object.
34
+
35
+ ### Express
36
+
37
+ ```js
38
+ import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
39
+
40
+ app.use(forgeOpsTrackerExpressMiddleware); // last, after all routes
41
+ ```
42
+
43
+ Requires Express 5+ -- its automatic forwarding of both synchronous throws *and* rejected promises
44
+ from `async` route handlers to error-handling middleware is what makes this work with zero other
45
+ wiring (verified directly against a real async handler, not assumed). Express 4 does not do this
46
+ for async handlers; each route would need its own try/catch there instead.
47
+
48
+ ### Fastify
49
+
50
+ ```js
51
+ import { registerForgeOpsTracker } from "@forge-ops/tracker/integrations/fastify";
52
+
53
+ registerForgeOpsTracker(app); // called directly, not via app.register()
54
+ ```
55
+
56
+ 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
58
+ avoids that entirely.
59
+
60
+ ## What gets reported automatically, and what doesn't
61
+
62
+ **An exception that crashes a request needs no further wiring at all.** Both integrations above
63
+ report anything that propagates uncaught out of a route handler, then let the framework handle it
64
+ exactly as if this client weren't installed.
65
+
66
+ **An exception your own code catches and handles is different -- neither integration ever sees
67
+ it**, since it never propagates far enough to reach either hook:
68
+
69
+ ```js
70
+ try {
71
+ await chargeCard(order);
72
+ } catch (err) {
73
+ logger.warn(`card declined: ${err.message}`);
74
+ // ForgeOps never sees this -- caught locally, never reaches the
75
+ // middleware/hook at all.
76
+ }
77
+ ```
78
+
79
+ There's no Express/Fastify-wide equivalent to Rails' `Rails.error.handle` here -- report it
80
+ explicitly instead, right at the catch site:
81
+
82
+ ```js
83
+ } catch (err) {
84
+ forgeOpsTracker.captureException(err, { orderId: order.id });
85
+ logger.warn(`card declined: ${err.message}`);
86
+ }
87
+ ```
88
+
89
+ ### Outside a web request (scripts, workers, unhandled promise rejections)
90
+
91
+ `init()` also installs `process` event listeners by default (`installProcessHandlers: false` to
92
+ opt out) covering two cases with no wiring needed:
93
+
94
+ - **`uncaughtExceptionMonitor`**, not `uncaughtException` -- deliberately. Registering any listener
95
+ for `uncaughtException` *suppresses Node's own default crash behavior entirely* (the process
96
+ would no longer exit on its own; Node's docs are explicit that resuming normal operation
97
+ 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
99
+ real uncaught throw, not assumed.
100
+ - **`unhandledRejection`** -- a rejected promise nobody awaited or attached a `.catch()` to,
101
+ arguably the most common way a modern async Node app silently fails.
102
+
103
+ Neither catches a web request's unhandled exception under Express/Fastify -- both catch that
104
+ themselves, long before it would ever reach here.
105
+
106
+ Every failure mode -- network errors, timeouts, a full queue, a malformed DSN -- is caught and
107
+ dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
108
+
109
+ ## Delivery: an async loop, not a thread
110
+
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.
119
+
120
+ ## `in_app` backtrace frames
121
+
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.
136
+
137
+ ## PII scrubbing
138
+
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) --
142
+ and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
143
+ regardless, so this is a second, earlier layer, not the only one.
144
+
145
+ To disable it:
146
+
147
+ ```js
148
+ forgeOpsTracker.init({ dsn: "...", scrubPii: false });
149
+ ```
150
+
151
+ ## Running the tests
152
+
153
+ ```bash
154
+ cd sdks/node
155
+ npm install
156
+ npm test
157
+ npm run lint
158
+ ```
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@forge-ops/tracker",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./integrations/express": "./src/integrations/express.js",
10
+ "./integrations/fastify": "./src/integrations/fastify.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "license": "MIT",
16
+ "author": "ForgeOps",
17
+ "homepage": "https://getforgeops.net",
18
+ "files": [
19
+ "src",
20
+ "LICENSE.txt"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test",
27
+ "lint": "eslint src test"
28
+ },
29
+ "peerDependencies": {
30
+ "express": ">=5",
31
+ "fastify": ">=4"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "express": { "optional": true },
35
+ "fastify": { "optional": true }
36
+ },
37
+ "devDependencies": {
38
+ "@eslint/js": "^10.0.0",
39
+ "eslint": "^10.0.0",
40
+ "express": "^5.0.0",
41
+ "fastify": "^5.0.0"
42
+ }
43
+ }
package/src/client.js ADDED
@@ -0,0 +1,50 @@
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
4
+ * `false` return rather than a thrown exception, since a broken or
5
+ * unreachable tracker must never be able to break the host app. Ported
6
+ * from gems/forge_ops_tracker/lib/forge_ops_tracker/client.rb.
7
+ *
8
+ * Uses the global fetch() (stable since Node 18), not a package
9
+ * dependency (axios, node-fetch, etc.) -- same reason the Ruby gem uses
10
+ * plain Net::HTTP and the Python/PHP clients use only their own
11
+ * standard library: this has to work in any host app without adding an
12
+ * HTTP client dependency of its own.
13
+ */
14
+ export class Client {
15
+ #configuration;
16
+
17
+ constructor(configuration) {
18
+ this.#configuration = configuration;
19
+ }
20
+
21
+ /** @param {Record<string, unknown>} payload */
22
+ async deliver(payload) {
23
+ const uri = this.#configuration.ingestionUri();
24
+ if (!uri) {
25
+ return false;
26
+ }
27
+
28
+ const controller = new AbortController();
29
+ const timeout = setTimeout(() => controller.abort(), this.#configuration.timeoutMs);
30
+
31
+ try {
32
+ const response = await fetch(uri, {
33
+ method: "POST",
34
+ headers: {
35
+ Authorization: `Bearer ${this.#configuration.apiKey()}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ body: JSON.stringify(payload),
39
+ signal: controller.signal,
40
+ });
41
+
42
+ return response.ok;
43
+ } catch (e) {
44
+ this.#configuration.log(`[forge-ops-tracker] delivery failed: ${e.name}: ${e.message}`);
45
+ return false;
46
+ } finally {
47
+ clearTimeout(timeout);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,94 @@
1
+ import os from "node:os";
2
+
3
+ /**
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
7
+ * the project's api_key: "https://<api_key>@host/api/v1/events".
8
+ */
9
+ export class Configuration {
10
+ dsn = process.env.FORGE_OPS_DSN ?? null;
11
+ environment = process.env.FORGE_OPS_ENVIRONMENT ?? process.env.NODE_ENV ?? "development";
12
+ release = process.env.FORGE_OPS_RELEASE ?? null;
13
+ serverName = safeHostname();
14
+
15
+ /**
16
+ * Used to decide whether a backtrace frame is "in_app": a frame's file
17
+ * path is compared against this root. Like the Ruby gem (and unlike
18
+ * the .NET SDK, where a compiled assembly's file path never matches
19
+ * its original source location), Node runs interpreted directly from
20
+ * real .js files on disk, so file-path matching is the correct
21
+ * approach here too. Defaults to the current working directory; set
22
+ * explicitly if that doesn't match your app's actual layout.
23
+ */
24
+ appRoot = process.cwd();
25
+
26
+ enabledEnvironments = new Set(["production", "staging"]);
27
+ queueSize = 1000;
28
+ timeoutMs = 2000;
29
+ scrubPii = true;
30
+
31
+ /** @type {((message: string) => void) | null} */
32
+ logger = null;
33
+
34
+ /** @returns {string | null} */
35
+ apiKey() {
36
+ const parsed = this.#parsedDsn();
37
+ if (parsed === null || parsed.username === "") {
38
+ return null;
39
+ }
40
+ return decodeURIComponent(parsed.username);
41
+ }
42
+
43
+ /**
44
+ * The ingestion URL with credentials stripped out (they travel as the
45
+ * Authorization header instead, not embedded in the request URI).
46
+ * @returns {string | null}
47
+ */
48
+ ingestionUri() {
49
+ const parsed = this.#parsedDsn();
50
+ if (parsed === null) {
51
+ return null;
52
+ }
53
+ const stripped = new URL(parsed.toString());
54
+ stripped.username = "";
55
+ stripped.password = "";
56
+ return stripped.toString();
57
+ }
58
+
59
+ /** @returns {boolean} */
60
+ isEnabled() {
61
+ return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
62
+ }
63
+
64
+ /** @param {string} message */
65
+ log(message) {
66
+ this.logger?.(message);
67
+ }
68
+
69
+ /** @returns {URL | null} */
70
+ #parsedDsn() {
71
+ if (!this.dsn) {
72
+ return null;
73
+ }
74
+ try {
75
+ // Unlike Ruby's URI.parse, Python's urlsplit, and PHP's parse_url
76
+ // (all lenient, never throwing on malformed input -- verified
77
+ // directly for each), Node's URL constructor genuinely throws a
78
+ // TypeError on a malformed string. Verified directly here too,
79
+ // not assumed -- hence this try/catch, which the other three
80
+ // clients don't need.
81
+ return new URL(this.dsn);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
87
+
88
+ function safeHostname() {
89
+ try {
90
+ return os.hostname();
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A small in-process bounded queue drained by an async processing loop,
3
+ * so delivery never blocks the caller that raised the error and never
4
+ * depends on the host app having any particular job backend configured.
5
+ * Ported from gems/forge_ops_tracker/lib/forge_ops_tracker/delivery_queue.rb.
6
+ *
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
9
+ * Node process is long-running across many requests, so async scheduling
10
+ * on the event loop is the natural, idiomatic substitute: push() returns
11
+ * immediately, and delivery happens via non-blocking I/O (fetch) on
12
+ * however many microtask turns it takes, without ever blocking the
13
+ * request that pushed it.
14
+ *
15
+ * The processing loop is started lazily, on first push, not at
16
+ * construction/import time -- mirroring the Ruby/Python clients rather
17
+ * than assuming eager start is safe. Node's `cluster` module can fork
18
+ * worker processes *after* the application (and this module) has
19
+ * already loaded, the same hazard Puma/Gunicorn have for those clients;
20
+ * starting fresh on first push means each forked worker gets its own
21
+ * live loop regardless of when it was forked relative to import.
22
+ */
23
+ export class DeliveryQueue {
24
+ #configuration;
25
+ #client;
26
+ #queue = [];
27
+ #processing = false;
28
+
29
+ constructor(configuration, client) {
30
+ this.#configuration = configuration;
31
+ this.#client = client;
32
+ }
33
+
34
+ /** @param {Record<string, unknown>} payload */
35
+ push(payload) {
36
+ const maxSize = Math.max(1, this.#configuration.queueSize);
37
+ if (this.#queue.length >= maxSize) {
38
+ this.#configuration.log("[forge-ops-tracker] delivery queue full, dropping event");
39
+ return false;
40
+ }
41
+
42
+ this.#queue.push(payload);
43
+ this.#ensureProcessing();
44
+ return true;
45
+ }
46
+
47
+ #ensureProcessing() {
48
+ if (this.#processing) {
49
+ return;
50
+ }
51
+ this.#processing = true;
52
+ // Deliberately not awaited -- this is the background loop itself;
53
+ // push() must return synchronously regardless of how long delivery
54
+ // takes.
55
+ this.#processLoop();
56
+ }
57
+
58
+ async #processLoop() {
59
+ while (this.#queue.length > 0) {
60
+ const payload = this.#queue.shift();
61
+ try {
62
+ await this.#client.deliver(payload);
63
+ } catch (e) {
64
+ // Per-item, not wrapping the whole loop: one bad delivery must
65
+ // not stop every event queued after it.
66
+ this.#configuration.log(`[forge-ops-tracker] delivery worker error: ${e.name}: ${e.message}`);
67
+ }
68
+ }
69
+ this.#processing = false;
70
+ }
71
+ }
@@ -0,0 +1,123 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { scrub, scrubString } from "./piiScrubber.js";
3
+
4
+ const MAX_FRAMES = 500;
5
+
6
+ // Parses a single V8 stack trace line, e.g.
7
+ // " at innerFunc (file:///app/src/foo.js:12:9)"
8
+ // " at file:///app/src/foo.js:8:3" (no named function)
9
+ // " at async asyncFn (node:internal/...:5:1)" (async frame, internal module)
10
+ // Verified directly against real captured stack traces (both sync and
11
+ // async, named and anonymous frames, and Node's internal node: module
12
+ // scheme) before relying on it, not assumed to match V8's format.
13
+ const FRAME_RE = /^\s*at\s+(?:(async)\s+)?(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/;
14
+
15
+ /**
16
+ * Turns a raised exception into the payload shape the ingestion API
17
+ * expects. Ported from
18
+ * gems/forge_ops_tracker/lib/forge_ops_tracker/event_builder.rb --
19
+ * backtrace parsing here is a regex over V8's Error#stack string, the
20
+ * same general approach as the Ruby gem's own regex over MRI backtrace
21
+ * lines (Node doesn't expose a structured frame list the way Python's
22
+ * traceback module or PHP's Throwable::getTrace() do).
23
+ */
24
+ export class EventBuilder {
25
+ #configuration;
26
+
27
+ constructor(configuration) {
28
+ this.#configuration = configuration;
29
+ }
30
+
31
+ /**
32
+ * @param {Error} error
33
+ * @param {Record<string, unknown>} [context]
34
+ */
35
+ build(error, context = {}) {
36
+ const payload = {
37
+ exception_class: error?.name ?? "Error",
38
+ message: error?.message ?? "",
39
+ backtrace: this.#backtrace(error),
40
+ occurred_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
41
+ environment: this.#configuration.environment,
42
+ release: this.#configuration.release,
43
+ server_name: this.#configuration.serverName,
44
+ context: { ...context },
45
+ tags: {},
46
+ };
47
+
48
+ return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
49
+ }
50
+
51
+ // exception_class/occurred_at/environment/release/server_name are left
52
+ // alone -- structured fields this client or the host app sets
53
+ // deliberately, not free text an exception or its context could
54
+ // accidentally spill sensitive data into.
55
+ #scrub(payload) {
56
+ return {
57
+ ...payload,
58
+ 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
+ })),
64
+ context: scrub(payload.context),
65
+ tags: scrub(payload.tags),
66
+ };
67
+ }
68
+
69
+ #backtrace(error) {
70
+ const stack = typeof error?.stack === "string" ? error.stack : "";
71
+ const frames = [];
72
+
73
+ for (const line of stack.split("\n")) {
74
+ if (frames.length >= MAX_FRAMES) {
75
+ break;
76
+ }
77
+ const match = FRAME_RE.exec(line);
78
+ if (!match) {
79
+ continue; // the "Error: message" header line, or anything unparseable
80
+ }
81
+
82
+ const [, , fn, rawFile, lineNo, ] = match;
83
+ const file = normalizeFile(rawFile);
84
+
85
+ frames.push({
86
+ file,
87
+ line: Number(lineNo),
88
+ method: fn ?? null,
89
+ in_app: this.#isInApp(file),
90
+ });
91
+ }
92
+
93
+ return frames;
94
+ }
95
+
96
+ #isInApp(file) {
97
+ const root = this.#configuration.appRoot;
98
+ if (!file || !root) {
99
+ return false;
100
+ }
101
+ if (!file.startsWith(root)) {
102
+ return false;
103
+ }
104
+ return !file.includes("/node_modules/");
105
+ }
106
+ }
107
+
108
+ // 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
110
+ // against Configuration#appRoot (always a plain path, from process.cwd()).
111
+ // Node's own internal modules (the "node:" scheme) are left as-is; they
112
+ // never match a real appRoot prefix anyway, so they're correctly never
113
+ // marked in_app without needing special-casing here.
114
+ function normalizeFile(rawFile) {
115
+ if (rawFile.startsWith("file://")) {
116
+ try {
117
+ return fileURLToPath(rawFile);
118
+ } catch {
119
+ return rawFile;
120
+ }
121
+ }
122
+ return rawFile;
123
+ }
package/src/index.js ADDED
@@ -0,0 +1,117 @@
1
+ import { Client } from "./client.js";
2
+ import { Configuration } from "./configuration.js";
3
+ import { DeliveryQueue } from "./deliveryQueue.js";
4
+ import { EventBuilder } from "./eventBuilder.js";
5
+ import { Reporter } from "./reporter.js";
6
+
7
+ export { Configuration };
8
+
9
+ let configuration = null;
10
+ let reporter = null;
11
+ let processHandlersInstalled = false;
12
+ let uncaughtExceptionListener = null;
13
+ let unhandledRejectionListener = null;
14
+
15
+ function getConfiguration() {
16
+ if (configuration === null) {
17
+ configuration = new Configuration();
18
+ }
19
+ return configuration;
20
+ }
21
+
22
+ function getReporter() {
23
+ if (reporter === null) {
24
+ const config = getConfiguration();
25
+ const client = new Client(config);
26
+ const deliveryQueue = new DeliveryQueue(config, client);
27
+ reporter = new Reporter(config, new EventBuilder(config), deliveryQueue);
28
+ }
29
+ return reporter;
30
+ }
31
+
32
+ /**
33
+ * Configure the client. Call once at startup.
34
+ *
35
+ * @param {Partial<Configuration> & { installProcessHandlers?: boolean }} [options]
36
+ * @returns {Configuration}
37
+ */
38
+ export function init(options = {}) {
39
+ const config = getConfiguration();
40
+ const { installProcessHandlers, ...overrides } = options;
41
+
42
+ for (const [key, value] of Object.entries(overrides)) {
43
+ if (!(key in config)) {
44
+ throw new TypeError(`Configuration has no property ${JSON.stringify(key)}`);
45
+ }
46
+ config[key] = value;
47
+ }
48
+
49
+ if (installProcessHandlers ?? true) {
50
+ installProcessLevelHandlers();
51
+ }
52
+
53
+ return config;
54
+ }
55
+
56
+ /**
57
+ * Report an exception you've already caught.
58
+ *
59
+ * @param {Error} error
60
+ * @param {Record<string, unknown>} [context]
61
+ */
62
+ export function captureException(error, context = {}) {
63
+ getReporter().report(error, context);
64
+ }
65
+
66
+ /**
67
+ * Reports anything that would otherwise crash the process outright (a
68
+ * plain script, an unhandled promise rejection) with no further wiring --
69
+ * the same "unhandled needs no wiring" case the Express/Fastify
70
+ * integrations cover for web requests.
71
+ *
72
+ * Deliberately listens on "uncaughtExceptionMonitor", not
73
+ * "uncaughtException" -- registering any listener for the latter
74
+ * *suppresses Node's own default crash behavior entirely* (the process
75
+ * would no longer exit on its own; official Node docs are explicit that
76
+ * resuming normal operation after an uncaught exception isn't safe).
77
+ * "uncaughtExceptionMonitor" exists specifically for observability tools
78
+ * like this one: it fires without changing what Node does afterward,
79
+ * confirmed directly (not assumed) against a real uncaught throw --
80
+ * Node still printed its usual crash output and exited with code 1.
81
+ *
82
+ * This does *not* catch a web request's unhandled exception under
83
+ * Express/Fastify -- both catch that themselves, long before it would
84
+ * ever reach here, which is what those integrations are for.
85
+ */
86
+ function installProcessLevelHandlers() {
87
+ if (processHandlersInstalled) {
88
+ return;
89
+ }
90
+ processHandlersInstalled = true;
91
+
92
+ uncaughtExceptionListener = (error) => {
93
+ captureException(error);
94
+ };
95
+ unhandledRejectionListener = (reason) => {
96
+ const error = reason instanceof Error ? reason : new Error(String(reason));
97
+ captureException(error);
98
+ };
99
+
100
+ process.on("uncaughtExceptionMonitor", uncaughtExceptionListener);
101
+ process.on("unhandledRejection", unhandledRejectionListener);
102
+ }
103
+
104
+ /** @internal not part of the public API -- resets module state between test cases */
105
+ export function _resetForTesting() {
106
+ if (uncaughtExceptionListener) {
107
+ process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
108
+ }
109
+ if (unhandledRejectionListener) {
110
+ process.off("unhandledRejection", unhandledRejectionListener);
111
+ }
112
+ configuration = null;
113
+ reporter = null;
114
+ processHandlersInstalled = false;
115
+ uncaughtExceptionListener = null;
116
+ unhandledRejectionListener = null;
117
+ }
@@ -0,0 +1,22 @@
1
+ import { captureException } from "../index.js";
2
+
3
+ /**
4
+ * Express error-handling middleware. Register last, after all routes:
5
+ *
6
+ * app.use(forgeOpsTrackerExpressMiddleware);
7
+ *
8
+ * Reports, then calls next(err) so Express's own error handling (a
9
+ * custom error handler registered after this one, or its default 500
10
+ * response) continues exactly as if this middleware weren't there.
11
+ * Express 5 forwards both synchronous throws and rejected promises from
12
+ * async route handlers to error-handling middleware automatically --
13
+ * verified directly against a real async handler, not assumed. Only an
14
+ * exception your own code catches and handles is invisible to this.
15
+ */
16
+ export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
17
+ captureException(err, {
18
+ path: req.path,
19
+ method: req.method,
20
+ });
21
+ next(err);
22
+ }
@@ -0,0 +1,27 @@
1
+ import { captureException } from "../index.js";
2
+
3
+ /**
4
+ * Called directly with your Fastify instance -- not registered via
5
+ * app.register(), which would create a new encapsulation scope and
6
+ * require the fastify-plugin package to break out of it:
7
+ *
8
+ * registerForgeOpsTracker(app);
9
+ *
10
+ * Adds an onError hook, which fires for any error Fastify's own
11
+ * lifecycle would otherwise handle -- both synchronous throws and
12
+ * rejected promises in async handlers, verified directly against a real
13
+ * async handler, not assumed. Doesn't set the reply itself, so Fastify's
14
+ * own error handling (setErrorHandler, or its default response)
15
+ * continues exactly as if this hook weren't registered. Only an
16
+ * exception your own code catches and handles is invisible to this.
17
+ *
18
+ * @param {import("fastify").FastifyInstance} fastify
19
+ */
20
+ export function registerForgeOpsTracker(fastify) {
21
+ fastify.addHook("onError", async (request, reply, error) => {
22
+ captureException(error, {
23
+ path: request.url,
24
+ method: request.method,
25
+ });
26
+ });
27
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Redacts likely-sensitive content out of a payload before it ever leaves
3
+ * this process -- the same patterns ForgeOps itself applies again on
4
+ * arrival (defense in depth: this layer keeps the data off the wire and
5
+ * out of any request logging in between; the server-side layer is what
6
+ * actually protects the database, and doesn't depend on every reporting
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
9
+ * standalone and dependency-free here for the same reason as the Ruby
10
+ * original: this has to work in any host app regardless of what's
11
+ * reporting into it.
12
+ *
13
+ * Can be turned off via Configuration#scrubPii = false for a host app
14
+ * that already scrubs its own data before it ever reaches exception
15
+ * context, or that has its own reasons to want the raw payload. Off by
16
+ * default is not an option: the safe default has to be "on."
17
+ */
18
+
19
+ export const REDACTED = "[FILTERED]";
20
+
21
+ const SENSITIVE_KEYS = new Set([
22
+ "password", "passwd", "pwd",
23
+ "secret", "apisecret", "clientsecret", "secretkey",
24
+ "token", "accesstoken", "refreshtoken", "apikey", "apitoken", "authorization", "authtoken", "bearer",
25
+ "sessiontoken", "csrftoken",
26
+ "creditcard", "cardnumber", "cardnum", "cvv", "cvv2", "cvc",
27
+ "ssn", "socialsecuritynumber", "socialsecurity",
28
+ "privatekey",
29
+ ]);
30
+
31
+ const PATTERNS = [
32
+ ["EMAIL", /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g],
33
+ ["SSN", /\b\d{3}-\d{2}-\d{4}\b/g],
34
+ ["CREDIT CARD", /\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,4}\b/g],
35
+ ["BEARER TOKEN", /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi],
36
+ ["JWT", /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
37
+ ["AWS KEY", /\bAKIA[0-9A-Z]{16}\b/g],
38
+ ["STRIPE KEY", /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g],
39
+ ["GITHUB TOKEN", /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g],
40
+ ];
41
+
42
+ /**
43
+ * @param {unknown} value
44
+ * @param {string | null} [key]
45
+ * @returns {unknown}
46
+ */
47
+ export function scrub(value, key = null) {
48
+ if (isSensitiveKey(key) && value !== null && value !== undefined) {
49
+ return REDACTED;
50
+ }
51
+
52
+ if (Array.isArray(value)) {
53
+ return value.map((v) => scrub(v, key));
54
+ }
55
+
56
+ if (value !== null && typeof value === "object") {
57
+ const scrubbed = {};
58
+ for (const [k, v] of Object.entries(value)) {
59
+ scrubbed[k] = scrub(v, k);
60
+ }
61
+ return scrubbed;
62
+ }
63
+
64
+ if (typeof value === "string") {
65
+ return scrubString(value);
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * @param {string} text
73
+ * @returns {string}
74
+ */
75
+ export function scrubString(text) {
76
+ let result = text;
77
+ for (const [label, pattern] of PATTERNS) {
78
+ result = result.replace(pattern, `[${label} FILTERED]`);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /** @param {string | null} key */
84
+ function isSensitiveKey(key) {
85
+ if (!key) {
86
+ return false;
87
+ }
88
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
89
+ for (const sensitive of SENSITIVE_KEYS) {
90
+ if (normalized.includes(sensitive)) {
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Ties Configuration, EventBuilder, and DeliveryQueue together into the
3
+ * one thing callers actually need: report an exception. Mirrors
4
+ * gems/forge_ops_tracker's ErrorSubscriber#report -- never throws. An
5
+ * error reporter that itself throws while reporting an error is the
6
+ * worst possible failure mode, so every path here is wrapped to
7
+ * guarantee this never propagates back into the host app.
8
+ */
9
+ export class Reporter {
10
+ #configuration;
11
+ #eventBuilder;
12
+ #deliveryQueue;
13
+
14
+ constructor(configuration, eventBuilder, deliveryQueue) {
15
+ this.#configuration = configuration;
16
+ this.#eventBuilder = eventBuilder;
17
+ this.#deliveryQueue = deliveryQueue;
18
+ }
19
+
20
+ /**
21
+ * @param {Error} error
22
+ * @param {Record<string, unknown>} [context]
23
+ */
24
+ report(error, context = {}) {
25
+ try {
26
+ if (!this.#configuration.isEnabled()) {
27
+ return;
28
+ }
29
+
30
+ const payload = this.#eventBuilder.build(error, context);
31
+ this.#deliveryQueue.push(payload);
32
+ } catch (e) {
33
+ this.#configuration.log(`[forge-ops-tracker] report failed: ${e.name}: ${e.message}`);
34
+ }
35
+ }
36
+ }