@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 +21 -0
- package/README.md +158 -0
- package/package.json +43 -0
- package/src/client.js +50 -0
- package/src/configuration.js +94 -0
- package/src/deliveryQueue.js +71 -0
- package/src/eventBuilder.js +123 -0
- package/src/index.js +117 -0
- package/src/integrations/express.js +22 -0
- package/src/integrations/fastify.js +27 -0
- package/src/piiScrubber.js +95 -0
- package/src/reporter.js +36 -0
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
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -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
|
+
}
|