@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 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
+ }
@@ -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
+ }
@@ -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
+ }