@realuptime/errors 0.4.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 +21 -0
- package/README.md +115 -0
- package/buffer.ts +52 -0
- package/client.ts +181 -0
- package/custom-scrub.ts +271 -0
- package/dist/buffer.d.ts +25 -0
- package/dist/buffer.js +42 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +151 -0
- package/dist/custom-scrub.d.ts +48 -0
- package/dist/custom-scrub.js +262 -0
- package/dist/feedback.d.ts +173 -0
- package/dist/feedback.js +465 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +516 -0
- package/dist/scrub.d.ts +60 -0
- package/dist/scrub.js +172 -0
- package/dist/sourcemaps.d.ts +142 -0
- package/dist/sourcemaps.js +262 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.js +79 -0
- package/dist/vite.d.ts +76 -0
- package/dist/vite.js +75 -0
- package/dist/webpack.d.ts +38 -0
- package/dist/webpack.js +15 -0
- package/feedback.ts +617 -0
- package/index.ts +617 -0
- package/package.json +62 -0
- package/scrub.ts +181 -0
- package/sourcemaps.ts +318 -0
- package/types.ts +293 -0
- package/vite.ts +155 -0
- package/webpack.ts +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RealUptime
|
|
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
|
|
13
|
+
all 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,115 @@
|
|
|
1
|
+
# @realuptime/errors
|
|
2
|
+
|
|
3
|
+
Zero-dependency error tracking SDK for JavaScript and Node: part of
|
|
4
|
+
[RealUptime Errors](https://realuptime.io/error-tracking).
|
|
5
|
+
|
|
6
|
+
This is a **mirror**. It is published from `packages/errors-js` in the
|
|
7
|
+
[realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
|
|
8
|
+
`scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
|
|
9
|
+
and PRs against this repo, but expect source changes to land here after
|
|
10
|
+
they merge upstream.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Installable from the package registry (primary) or straight from this mirror's git history (e.g. to pin an exact commit).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @realuptime/errors
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# or, straight from this mirror
|
|
22
|
+
npm install github:RealUptimeHQ/realuptime-errors-js
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import * as realuptimeErrors from "@realuptime/errors";
|
|
29
|
+
|
|
30
|
+
realuptimeErrors.init({
|
|
31
|
+
dsn: "https://realuptime.io/api/errors/v1/ingest/rue_...", // from your project's dashboard
|
|
32
|
+
release: "v2.4.1",
|
|
33
|
+
environment: "production",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
realuptimeErrors.captureException(someError);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
WSGI/ASGI equivalents live in the Python SDK,
|
|
40
|
+
[realuptime-errors-py](https://github.com/RealUptimeHQ/realuptime-errors-py).
|
|
41
|
+
|
|
42
|
+
## Source maps (Vite / webpack plugins)
|
|
43
|
+
|
|
44
|
+
Upload the `.map` files a production build writes, for the release your
|
|
45
|
+
app reports at runtime, so minified JS frames symbolicate on the issue page.
|
|
46
|
+
Both plugins read the maps the bundler already wrote and POST them to your
|
|
47
|
+
project's source-map endpoint (authenticated by the DSN, the one secret CI
|
|
48
|
+
already has). They change nothing in the build output.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// vite.config.js
|
|
52
|
+
import { realuptimeSourcemaps } from "@realuptime/errors/vite";
|
|
53
|
+
export default {
|
|
54
|
+
build: { sourcemap: true },
|
|
55
|
+
plugins: [realuptimeSourcemaps({ release: process.env.GITHUB_SHA, dsn: process.env.REALUPTIME_ERRORS_DSN })],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// webpack.config.js
|
|
59
|
+
const { RealuptimeSourcemapsPlugin } = require("@realuptime/errors/webpack");
|
|
60
|
+
module.exports = { devtool: "source-map", plugins: [new RealuptimeSourcemapsPlugin({ release: process.env.GITHUB_SHA })] };
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
An upload failure is logged with the server's own message and the build
|
|
64
|
+
still succeeds; pass `failOnError: true` to fail the build instead. With no
|
|
65
|
+
DSN set (local builds) the plugin logs one line and uploads nothing. Limits:
|
|
66
|
+
2MB per map, 50 maps per release, 10 files per request (batched for you).
|
|
67
|
+
|
|
68
|
+
## What this SDK actually does
|
|
69
|
+
|
|
70
|
+
- **Never crashes your app.** Every public function catches everything;
|
|
71
|
+
a broken SDK logs once (prefixed `[realuptime-errors]`) and goes quiet.
|
|
72
|
+
An error tracker that crashes the app it watches is worse than none.
|
|
73
|
+
- **Scrubs PII by default, client-side**, before anything serializes:
|
|
74
|
+
`Authorization`/`Proxy-Authorization`/`Cookie`/`Set-Cookie` headers are
|
|
75
|
+
replaced whole; card-shaped digit runs (Luhn-checked), JWTs, prefixed API
|
|
76
|
+
keys, and long hex/base64 runs are pattern-scrubbed anywhere they appear
|
|
77
|
+
in the message, request context, breadcrumbs, or captured source lines.
|
|
78
|
+
Opt one field back in with `allowFields: ["x-request-id"]`; there is no
|
|
79
|
+
global "disable scrubbing" switch, by design, and an allowed field's value
|
|
80
|
+
still passes the pattern rules.
|
|
81
|
+
- **Never silently drops.** The in-memory buffer (200 events) evicts the
|
|
82
|
+
oldest event when full, counts every eviction, and reports the count on
|
|
83
|
+
the next successful batch, so drops show up on your dashboard instead of
|
|
84
|
+
disappearing.
|
|
85
|
+
- **Zero runtime dependencies.** `dependencies` in `package.json` is
|
|
86
|
+
always `{}`, enforced by `wire-contract.test.ts` in this repo, not just
|
|
87
|
+
claimed in prose.
|
|
88
|
+
|
|
89
|
+
## Config (`init`)
|
|
90
|
+
|
|
91
|
+
| Option | Type | Default | Notes |
|
|
92
|
+
| --- | --- | --- | --- |
|
|
93
|
+
| `dsn` | `string` | required | Your project's ingest URL, shown once at creation. Safe in client-side code: it can only submit events to that one project. |
|
|
94
|
+
| `release` | `string?` | none | e.g. a git SHA or version tag. |
|
|
95
|
+
| `environment` | `string?` | none | e.g. `"production"`. |
|
|
96
|
+
| `allowFields` | `string[]?` | `[]` | Lower-cased header/field names to opt back into capture. |
|
|
97
|
+
| `captureUnhandled` | `boolean` | `true` | Installs process/window handlers for uncaught exceptions and rejections. |
|
|
98
|
+
|
|
99
|
+
## Source and build
|
|
100
|
+
|
|
101
|
+
`index.ts`, `client.ts`, `buffer.ts`, `scrub.ts`, `custom-scrub.ts`, and
|
|
102
|
+
`types.ts` are the real, tested source (see the `.test.ts` files beside them). `dist/` is a
|
|
103
|
+
plain compile of that source (TypeScript's own compiler, no bundler, ES2022
|
|
104
|
+
modules, declarations included) and is what `package.json` actually points
|
|
105
|
+
`import`/`types` at, so a plain `npm install` plus `import` works with no
|
|
106
|
+
extra build step or type-stripping flag on the consumer's side.
|
|
107
|
+
|
|
108
|
+
## Version
|
|
109
|
+
|
|
110
|
+
This mirror tracks SDK_VERSION `0.4.0` in `index.ts`, the string every
|
|
111
|
+
event actually carries on the wire.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT, see [LICENSE](./LICENSE).
|
package/buffer.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { WireEvent } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded in-memory event buffer, modelled on apps/agent/buffer.ts: oldest
|
|
5
|
+
* dropped when full, every drop counted, and the count is reported in the
|
|
6
|
+
* next successful batch (`droppedClient` on the wire) so even client-side
|
|
7
|
+
* drops reach the product's visible drop counters. In-memory only; an error
|
|
8
|
+
* SDK that writes to the customer's filesystem is over the line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BUFFER_MAX = 200;
|
|
12
|
+
|
|
13
|
+
export class EventBuffer {
|
|
14
|
+
private events: WireEvent[] = [];
|
|
15
|
+
private droppedSinceReport = 0;
|
|
16
|
+
|
|
17
|
+
push(event: WireEvent): void {
|
|
18
|
+
if (this.events.length >= BUFFER_MAX) {
|
|
19
|
+
this.events.shift();
|
|
20
|
+
this.droppedSinceReport += 1;
|
|
21
|
+
}
|
|
22
|
+
this.events.push(event);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get length(): number {
|
|
26
|
+
return this.events.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Reads up to `n` events without removing them; `commit(n)` removes them
|
|
30
|
+
* only after the server acknowledged the batch, so a failed delivery
|
|
31
|
+
* retries the same events instead of losing them. */
|
|
32
|
+
peekBatch(n: number): WireEvent[] {
|
|
33
|
+
return this.events.slice(0, n);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
commit(n: number): void {
|
|
37
|
+
this.events.splice(0, n);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The drop count to carry on the NEXT batch. Taken (reset to zero) only
|
|
41
|
+
* when the caller is about to put it on the wire; if that delivery fails
|
|
42
|
+
* the caller must give it back via `restoreDropped`. */
|
|
43
|
+
takeDropped(): number {
|
|
44
|
+
const n = this.droppedSinceReport;
|
|
45
|
+
this.droppedSinceReport = 0;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
restoreDropped(n: number): void {
|
|
50
|
+
this.droppedSinceReport += n;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/client.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { EventBuffer } from "./buffer.ts";
|
|
2
|
+
import { MAX_EVENTS_PER_BATCH, type WireBatch, type WireEvent, type WireIngestResult } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Delivery: batched, buffered, backed off, and incapable of crashing the
|
|
6
|
+
* host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
|
|
7
|
+
* apps/agent/api.ts and runtime.ts:
|
|
8
|
+
*
|
|
9
|
+
* - Failures never throw out of the SDK. Everything is caught; the first
|
|
10
|
+
* failure of each kind logs ONCE and further ones are silent, because
|
|
11
|
+
* an error tracker that floods the console is nearly as bad as one that
|
|
12
|
+
* throws.
|
|
13
|
+
* - Transient failures (network, 5xx, unparseable body) back off
|
|
14
|
+
* exponentially and the events stay buffered.
|
|
15
|
+
* - `over_quota` responses stop delivery until the reset instant the
|
|
16
|
+
* server names. Retrying into a hard cap burns the customer's cycles
|
|
17
|
+
* and our ingest budget for nothing; the drop already happened and is
|
|
18
|
+
* already counted server-side.
|
|
19
|
+
* - `key_revoked` (403) disables the SDK for the process lifetime after
|
|
20
|
+
* one log line naming the fix. A revoked key never becomes an event
|
|
21
|
+
* black hole or a retry storm.
|
|
22
|
+
* - `rate_limited` (429) is transient: back off and keep the events.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
26
|
+
export const BACKOFF_START_MS = 5_000;
|
|
27
|
+
export const BACKOFF_MAX_MS = 300_000;
|
|
28
|
+
|
|
29
|
+
export interface TransportOptions {
|
|
30
|
+
endpoint: string;
|
|
31
|
+
sdk: string;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
log?: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RefusalBody {
|
|
38
|
+
error?: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
resetsAt?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class Transport {
|
|
44
|
+
private readonly buffer = new EventBuffer();
|
|
45
|
+
private readonly fetchImpl: typeof fetch;
|
|
46
|
+
private readonly now: () => number;
|
|
47
|
+
private readonly log: (message: string) => void;
|
|
48
|
+
private backoffMs = 0;
|
|
49
|
+
private nextAttemptAt = 0;
|
|
50
|
+
private pausedUntil = 0;
|
|
51
|
+
private disabled = false;
|
|
52
|
+
private loggedKinds = new Set<string>();
|
|
53
|
+
private inFlight: Promise<void> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(private readonly options: TransportOptions) {
|
|
56
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
57
|
+
this.now = options.now ?? Date.now;
|
|
58
|
+
this.log = options.log ?? ((message) => console.error(message));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
enqueue(event: WireEvent): void {
|
|
62
|
+
if (this.disabled) return;
|
|
63
|
+
this.buffer.push(event);
|
|
64
|
+
void this.flush();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sends what is due. Serialized: one delivery in flight at a time, so a
|
|
68
|
+
* burst of captures becomes one batch, not a request per event. */
|
|
69
|
+
flush(): Promise<void> {
|
|
70
|
+
if (this.inFlight) return this.inFlight;
|
|
71
|
+
this.inFlight = this.deliver().finally(() => {
|
|
72
|
+
this.inFlight = null;
|
|
73
|
+
});
|
|
74
|
+
return this.inFlight;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private logOnce(kind: string, message: string): void {
|
|
78
|
+
if (this.loggedKinds.has(kind)) return;
|
|
79
|
+
this.loggedKinds.add(kind);
|
|
80
|
+
this.log(`[realuptime-errors] ${message}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async deliver(): Promise<void> {
|
|
84
|
+
while (this.buffer.length > 0) {
|
|
85
|
+
const now = this.now();
|
|
86
|
+
if (this.disabled || now < this.nextAttemptAt || now < this.pausedUntil) return;
|
|
87
|
+
|
|
88
|
+
const events = this.buffer.peekBatch(MAX_EVENTS_PER_BATCH);
|
|
89
|
+
const droppedClient = this.buffer.takeDropped();
|
|
90
|
+
const batch: WireBatch = { sdk: this.options.sdk, droppedClient, events };
|
|
91
|
+
|
|
92
|
+
let response: Response;
|
|
93
|
+
try {
|
|
94
|
+
response = await this.fetchImpl(this.options.endpoint, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify(batch),
|
|
98
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
this.buffer.restoreDropped(droppedClient);
|
|
102
|
+
this.backOff("network", "cannot reach the ingest endpoint; buffering and retrying");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (response.ok) {
|
|
107
|
+
this.buffer.commit(events.length);
|
|
108
|
+
this.backoffMs = 0;
|
|
109
|
+
this.nextAttemptAt = 0;
|
|
110
|
+
let body: WireIngestResult | null = null;
|
|
111
|
+
try {
|
|
112
|
+
body = (await response.json()) as WireIngestResult;
|
|
113
|
+
} catch {
|
|
114
|
+
body = null;
|
|
115
|
+
}
|
|
116
|
+
if (body?.overQuota) {
|
|
117
|
+
this.pauseUntil(body.quota?.resetsAt);
|
|
118
|
+
this.logOnce(
|
|
119
|
+
"over-quota",
|
|
120
|
+
`monthly event quota reached (${body.quota?.limit ?? "?"}); pausing until ${body.quota?.resetsAt ?? "the window resets"}. Dropped events are counted and shown on your realuptime Errors dashboard.`,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let refusal: RefusalBody = {};
|
|
128
|
+
try {
|
|
129
|
+
refusal = (await response.json()) as RefusalBody;
|
|
130
|
+
} catch {
|
|
131
|
+
refusal = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (response.status === 403 && refusal.reason === "key_revoked") {
|
|
135
|
+
this.disabled = true;
|
|
136
|
+
this.logOnce(
|
|
137
|
+
"revoked",
|
|
138
|
+
"this project's ingest key was revoked; error reporting is disabled for this process. Rotate the key in realuptime Errors settings and redeploy with the new DSN.",
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 429 && refusal.reason === "over_quota") {
|
|
143
|
+
// The whole batch was refused at the cap: nothing was written, and
|
|
144
|
+
// holding the events would only re-refuse them all window long.
|
|
145
|
+
this.buffer.commit(events.length);
|
|
146
|
+
this.pauseUntil(refusal.resetsAt);
|
|
147
|
+
this.logOnce(
|
|
148
|
+
"over-quota",
|
|
149
|
+
`monthly event quota reached; pausing until ${refusal.resetsAt ?? "the window resets"}. Dropped events are counted and shown on your realuptime Errors dashboard.`,
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (response.status === 400) {
|
|
154
|
+
// A malformed batch will never become well-formed by retrying; drop
|
|
155
|
+
// it rather than wedging the buffer, and say so once.
|
|
156
|
+
this.buffer.commit(events.length);
|
|
157
|
+
this.buffer.restoreDropped(droppedClient);
|
|
158
|
+
this.logOnce("malformed", `the server refused a batch as malformed: ${refusal.error ?? "no detail"}. This is an SDK bug worth reporting.`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.buffer.restoreDropped(droppedClient);
|
|
163
|
+
this.backOff("transient", `ingest endpoint answered ${response.status}; buffering and retrying`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private pauseUntil(resetsAt: string | undefined): void {
|
|
169
|
+
const parsed = resetsAt ? Date.parse(resetsAt) : Number.NaN;
|
|
170
|
+
// A missing or unparseable reset instant still pauses meaningfully: an
|
|
171
|
+
// hour is long enough to stop the burn, short enough to recover early
|
|
172
|
+
// in a new window.
|
|
173
|
+
this.pausedUntil = Number.isFinite(parsed) ? parsed : this.now() + 60 * 60_000;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private backOff(kind: string, message: string): void {
|
|
177
|
+
this.backoffMs = this.backoffMs <= 0 ? BACKOFF_START_MS : Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
178
|
+
this.nextAttemptAt = this.now() + this.backoffMs;
|
|
179
|
+
this.logOnce(kind, `${message} (backing off).`);
|
|
180
|
+
}
|
|
181
|
+
}
|
package/custom-scrub.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { SCRUBBED } from "./scrub.ts";
|
|
2
|
+
import type { WireEvent } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Customer-defined ADDITIONAL scrub rules, client side (REA-264). The rules
|
|
6
|
+
* a project's owner set in the realuptime Errors settings are fetched once
|
|
7
|
+
* at init (see index.ts, `fetchScrubRules`) and applied here AFTER the
|
|
8
|
+
* default scrub (scrub.ts), never instead of it. There is nothing in this
|
|
9
|
+
* file that can relax a default: a rule can only add a field name, a key
|
|
10
|
+
* glob, or a value regex that turns more into `[scrubbed]`.
|
|
11
|
+
*
|
|
12
|
+
* DUPLICATED from packages/db/error-scrub-rules.ts, the scrub.ts
|
|
13
|
+
* convention: this package must stay import-free. custom-scrub-vectors.json
|
|
14
|
+
* pins the two (and the Python SDK) to byte-identical output; a semantic
|
|
15
|
+
* change is a three-implementation change.
|
|
16
|
+
*
|
|
17
|
+
* The validator is carried here too, even though rules only ever arrive
|
|
18
|
+
* from our own endpoint, because "only ever" is not a guarantee the host
|
|
19
|
+
* app should depend on: a rule that fails the same safe-subset check the
|
|
20
|
+
* server applies is skipped, so no network response can hand this process
|
|
21
|
+
* a catastrophic-backtracking pattern.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type CustomScrubRuleKind = "field_name" | "key_glob" | "value_regex";
|
|
25
|
+
export interface CustomScrubRule {
|
|
26
|
+
kind: CustomScrubRuleKind;
|
|
27
|
+
pattern: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const KINDS: readonly string[] = ["field_name", "key_glob", "value_regex"];
|
|
31
|
+
const MAX_PATTERN_LENGTH = 200;
|
|
32
|
+
const MAX_QUANTIFIERS = 10;
|
|
33
|
+
const MAX_REPEAT = 100;
|
|
34
|
+
/** Most rules a response may carry; matches the server's per-project cap. */
|
|
35
|
+
export const MAX_CUSTOM_SCRUB_RULES = 50;
|
|
36
|
+
|
|
37
|
+
export function validateCustomScrubRule(input: { kind: unknown; pattern: unknown }): boolean {
|
|
38
|
+
if (typeof input?.kind !== "string" || !KINDS.includes(input.kind)) return false;
|
|
39
|
+
if (typeof input.pattern !== "string") return false;
|
|
40
|
+
const pattern = input.pattern;
|
|
41
|
+
if (pattern.trim().length === 0 || pattern.length > MAX_PATTERN_LENGTH) return false;
|
|
42
|
+
if (/[\r\n]/.test(pattern)) return false;
|
|
43
|
+
if (input.kind === "field_name" || input.kind === "key_glob") {
|
|
44
|
+
if (pattern !== pattern.trim()) return false;
|
|
45
|
+
const hasWildcard = /[*?]/.test(pattern);
|
|
46
|
+
return input.kind === "key_glob" ? hasWildcard : !hasWildcard;
|
|
47
|
+
}
|
|
48
|
+
return safeRegex(pattern);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeRegex(pattern: string): boolean {
|
|
52
|
+
const groupHasQuantifier: boolean[] = [];
|
|
53
|
+
let quantifiers = 0;
|
|
54
|
+
let inClass = false;
|
|
55
|
+
let lastClosedGroupHadQuantifier = false;
|
|
56
|
+
let lastWasQuantifier = false;
|
|
57
|
+
let lastWasAtom = false;
|
|
58
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
59
|
+
const ch = pattern[i];
|
|
60
|
+
if (ch === "\\") {
|
|
61
|
+
const next = pattern[i + 1];
|
|
62
|
+
if (next === undefined) return false;
|
|
63
|
+
if (/[1-9]/.test(next) || next === "k") return false;
|
|
64
|
+
if (next === "p" || next === "P" || next === "u" || next === "c") return false;
|
|
65
|
+
i += 1;
|
|
66
|
+
if (!inClass) {
|
|
67
|
+
lastWasAtom = true;
|
|
68
|
+
lastWasQuantifier = false;
|
|
69
|
+
lastClosedGroupHadQuantifier = false;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (inClass) {
|
|
74
|
+
if (ch === "]") inClass = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "[") {
|
|
78
|
+
inClass = true;
|
|
79
|
+
if (pattern[i + 1] === "^") i += 1;
|
|
80
|
+
if (pattern[i + 1] === "]") i += 1;
|
|
81
|
+
lastWasAtom = true;
|
|
82
|
+
lastWasQuantifier = false;
|
|
83
|
+
lastClosedGroupHadQuantifier = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === "(") {
|
|
87
|
+
if (pattern[i + 1] === "?") {
|
|
88
|
+
if (pattern[i + 2] !== ":") return false;
|
|
89
|
+
i += 2;
|
|
90
|
+
}
|
|
91
|
+
groupHasQuantifier.push(false);
|
|
92
|
+
lastWasAtom = false;
|
|
93
|
+
lastWasQuantifier = false;
|
|
94
|
+
lastClosedGroupHadQuantifier = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === ")") {
|
|
98
|
+
const had = groupHasQuantifier.pop();
|
|
99
|
+
if (had === undefined) return false;
|
|
100
|
+
lastClosedGroupHadQuantifier = had;
|
|
101
|
+
if (had && groupHasQuantifier.length > 0) groupHasQuantifier[groupHasQuantifier.length - 1] = true;
|
|
102
|
+
lastWasAtom = true;
|
|
103
|
+
lastWasQuantifier = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const isBrace = ch === "{";
|
|
107
|
+
if (ch === "*" || ch === "+" || ch === "?" || isBrace) {
|
|
108
|
+
if (isBrace) {
|
|
109
|
+
const close = pattern.indexOf("}", i);
|
|
110
|
+
const body = close === -1 ? null : pattern.slice(i + 1, close);
|
|
111
|
+
const m = body === null ? null : /^(\d+)(?:,(\d*))?$/.exec(body);
|
|
112
|
+
if (!m) {
|
|
113
|
+
lastWasAtom = true;
|
|
114
|
+
lastWasQuantifier = false;
|
|
115
|
+
lastClosedGroupHadQuantifier = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const lo = Number(m[1]);
|
|
119
|
+
const hi = m[2] === undefined ? lo : m[2] === "" ? Number.POSITIVE_INFINITY : Number(m[2]);
|
|
120
|
+
if (lo > MAX_REPEAT || (Number.isFinite(hi) && hi > MAX_REPEAT)) return false;
|
|
121
|
+
if (Number.isFinite(hi) && hi < lo) return false;
|
|
122
|
+
i = close;
|
|
123
|
+
}
|
|
124
|
+
if (ch === "?" && lastWasQuantifier) {
|
|
125
|
+
lastWasQuantifier = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!lastWasAtom) return false;
|
|
129
|
+
if (lastClosedGroupHadQuantifier) return false;
|
|
130
|
+
quantifiers += 1;
|
|
131
|
+
if (quantifiers > MAX_QUANTIFIERS) return false;
|
|
132
|
+
if (groupHasQuantifier.length > 0) groupHasQuantifier[groupHasQuantifier.length - 1] = true;
|
|
133
|
+
lastWasAtom = false;
|
|
134
|
+
lastWasQuantifier = true;
|
|
135
|
+
lastClosedGroupHadQuantifier = false;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === "|") {
|
|
139
|
+
lastWasAtom = false;
|
|
140
|
+
lastWasQuantifier = false;
|
|
141
|
+
lastClosedGroupHadQuantifier = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
lastWasAtom = true;
|
|
145
|
+
lastWasQuantifier = false;
|
|
146
|
+
lastClosedGroupHadQuantifier = false;
|
|
147
|
+
}
|
|
148
|
+
if (inClass || groupHasQuantifier.length > 0) return false;
|
|
149
|
+
let compiled: RegExp;
|
|
150
|
+
try {
|
|
151
|
+
compiled = new RegExp(pattern, "g");
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return !new RegExp(compiled.source).test("");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface CompiledCustomScrubRules {
|
|
159
|
+
matchesKey: (name: string) => boolean;
|
|
160
|
+
valueRegexes: RegExp[];
|
|
161
|
+
count: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function globToRegExp(glob: string): RegExp {
|
|
165
|
+
let out = "^";
|
|
166
|
+
for (const ch of glob) {
|
|
167
|
+
if (ch === "*") out += ".*";
|
|
168
|
+
else if (ch === "?") out += ".";
|
|
169
|
+
else out += ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
170
|
+
}
|
|
171
|
+
return new RegExp(`${out}$`, "i");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compiles rules, skipping any that fail validation. Never throws. */
|
|
175
|
+
export function compileCustomScrubRules(rules: readonly unknown[]): CompiledCustomScrubRules {
|
|
176
|
+
const names = new Set<string>();
|
|
177
|
+
const globs: RegExp[] = [];
|
|
178
|
+
const valueRegexes: RegExp[] = [];
|
|
179
|
+
let count = 0;
|
|
180
|
+
for (const raw of rules.slice(0, MAX_CUSTOM_SCRUB_RULES)) {
|
|
181
|
+
const rule = raw as { kind?: unknown; pattern?: unknown };
|
|
182
|
+
if (!rule || typeof rule !== "object" || !validateCustomScrubRule({ kind: rule.kind, pattern: rule.pattern })) continue;
|
|
183
|
+
count += 1;
|
|
184
|
+
const pattern = rule.pattern as string;
|
|
185
|
+
if (rule.kind === "field_name") names.add(pattern.toLowerCase());
|
|
186
|
+
else if (rule.kind === "key_glob") globs.push(globToRegExp(pattern));
|
|
187
|
+
else valueRegexes.push(new RegExp(pattern, "g"));
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
matchesKey: (name) => {
|
|
191
|
+
const lower = name.toLowerCase();
|
|
192
|
+
if (names.has(lower)) return true;
|
|
193
|
+
for (const glob of globs) if (glob.test(lower)) return true;
|
|
194
|
+
return false;
|
|
195
|
+
},
|
|
196
|
+
valueRegexes,
|
|
197
|
+
count,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const NO_CUSTOM_SCRUB_RULES: CompiledCustomScrubRules = { matchesKey: () => false, valueRegexes: [], count: 0 };
|
|
202
|
+
|
|
203
|
+
function applyValue(value: string, compiled: CompiledCustomScrubRules): string {
|
|
204
|
+
let out = value;
|
|
205
|
+
for (const re of compiled.valueRegexes) {
|
|
206
|
+
re.lastIndex = 0;
|
|
207
|
+
out = out.replace(re, SCRUBBED);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Applies compiled rules to an event that has ALREADY been through
|
|
213
|
+
* scrubEvent. Returns a new object; never mutates the input. */
|
|
214
|
+
export function applyCustomScrubRules(event: WireEvent, compiled: CompiledCustomScrubRules): WireEvent {
|
|
215
|
+
if (compiled.count === 0) return event;
|
|
216
|
+
const value = (input: string): string => applyValue(input, compiled);
|
|
217
|
+
const entry = (name: string, input: string): string => (compiled.matchesKey(name) ? SCRUBBED : value(input));
|
|
218
|
+
const map = <M extends object>(input: M, prefix = ""): M => {
|
|
219
|
+
const out: Record<string, unknown> = {};
|
|
220
|
+
for (const [name, v] of Object.entries(input)) out[name] = typeof v === "string" ? entry(prefix + name, v) : v;
|
|
221
|
+
return out as M;
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
...event,
|
|
225
|
+
message: value(event.message),
|
|
226
|
+
request: event.request
|
|
227
|
+
? {
|
|
228
|
+
...event.request,
|
|
229
|
+
...(typeof event.request.path === "string" ? { path: value(event.request.path) } : {}),
|
|
230
|
+
...(typeof event.request.route === "string" ? { route: value(event.request.route) } : {}),
|
|
231
|
+
...(event.request.headers ? { headers: map(event.request.headers) } : {}),
|
|
232
|
+
}
|
|
233
|
+
: event.request,
|
|
234
|
+
breadcrumbs: event.breadcrumbs
|
|
235
|
+
? event.breadcrumbs.map((crumb) => ({
|
|
236
|
+
...crumb,
|
|
237
|
+
message: value(crumb.message),
|
|
238
|
+
...(crumb.data ? { data: map(crumb.data) } : {}),
|
|
239
|
+
}))
|
|
240
|
+
: event.breadcrumbs,
|
|
241
|
+
frames: event.frames
|
|
242
|
+
? event.frames.map((frame) =>
|
|
243
|
+
frame.contextLine != null || frame.preContext?.length || frame.postContext?.length || frame.vars
|
|
244
|
+
? {
|
|
245
|
+
...frame,
|
|
246
|
+
...(frame.contextLine != null ? { contextLine: value(frame.contextLine) } : {}),
|
|
247
|
+
...(frame.preContext ? { preContext: frame.preContext.map(value) } : {}),
|
|
248
|
+
...(frame.postContext ? { postContext: frame.postContext.map(value) } : {}),
|
|
249
|
+
...(frame.vars ? { vars: map(frame.vars) } : {}),
|
|
250
|
+
}
|
|
251
|
+
: frame,
|
|
252
|
+
)
|
|
253
|
+
: event.frames,
|
|
254
|
+
...(event.user ? { user: map(event.user, "user.") } : {}),
|
|
255
|
+
...(event.tags ? { tags: map(event.tags) } : {}),
|
|
256
|
+
...(event.context ? { context: map(event.context) } : {}),
|
|
257
|
+
...(event.device ? { device: map(event.device) } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The rules endpoint for a DSN: the ingest URL with `/ingest/` swapped for
|
|
263
|
+
* `/scrub-rules/`. Returns null for a DSN that is not ingest-shaped, in
|
|
264
|
+
* which case no fetch is attempted and the defaults run alone.
|
|
265
|
+
*/
|
|
266
|
+
export function scrubRulesUrlFor(dsn: string): string | null {
|
|
267
|
+
const marker = "/api/errors/v1/ingest/";
|
|
268
|
+
const at = dsn.indexOf(marker);
|
|
269
|
+
if (at === -1) return null;
|
|
270
|
+
return `${dsn.slice(0, at)}/api/errors/v1/scrub-rules/${dsn.slice(at + marker.length)}`;
|
|
271
|
+
}
|
package/dist/buffer.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WireEvent } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Bounded in-memory event buffer, modelled on apps/agent/buffer.ts: oldest
|
|
4
|
+
* dropped when full, every drop counted, and the count is reported in the
|
|
5
|
+
* next successful batch (`droppedClient` on the wire) so even client-side
|
|
6
|
+
* drops reach the product's visible drop counters. In-memory only; an error
|
|
7
|
+
* SDK that writes to the customer's filesystem is over the line.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BUFFER_MAX = 200;
|
|
10
|
+
export declare class EventBuffer {
|
|
11
|
+
private events;
|
|
12
|
+
private droppedSinceReport;
|
|
13
|
+
push(event: WireEvent): void;
|
|
14
|
+
get length(): number;
|
|
15
|
+
/** Reads up to `n` events without removing them; `commit(n)` removes them
|
|
16
|
+
* only after the server acknowledged the batch, so a failed delivery
|
|
17
|
+
* retries the same events instead of losing them. */
|
|
18
|
+
peekBatch(n: number): WireEvent[];
|
|
19
|
+
commit(n: number): void;
|
|
20
|
+
/** The drop count to carry on the NEXT batch. Taken (reset to zero) only
|
|
21
|
+
* when the caller is about to put it on the wire; if that delivery fails
|
|
22
|
+
* the caller must give it back via `restoreDropped`. */
|
|
23
|
+
takeDropped(): number;
|
|
24
|
+
restoreDropped(n: number): void;
|
|
25
|
+
}
|