@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 +91 -42
- package/package.json +9 -4
- package/src/client.js +16 -4
- package/src/configuration.js +42 -4
- package/src/deliveryQueue.js +3 -3
- package/src/eventBuilder.js +101 -14
- package/src/index.js +31 -5
- package/src/integrations/express.js +7 -1
- package/src/integrations/fastify.js +2 -2
- package/src/integrations/sessionTracking.js +32 -0
- package/src/piiScrubber.js +2 -2
- package/src/reporter.js +1 -1
- package/src/sessionFlusher.js +91 -0
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# @forge-ops/tracker
|
|
2
2
|
|
|
3
|
-
Node.js error reporting client for a
|
|
4
|
-
Requires Node 18+ (for global `fetch`).
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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(
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
forked
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
stack
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
for likely personal data
|
|
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.
|
|
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": {
|
|
35
|
-
|
|
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
|
|
3
|
-
* timeout, TLS, a non-2xx response
|
|
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.)
|
|
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
|
-
|
|
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
|
}
|
package/src/configuration.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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 {
|
package/src/deliveryQueue.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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();
|
package/src/eventBuilder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/piiScrubber.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Redacts likely-sensitive content out of a payload before it ever leaves
|
|
3
|
-
* this process
|
|
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
|
|
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
|
|
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
|
+
}
|