@realuptime/errors 0.4.0 → 0.5.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  Zero-dependency error tracking SDK for JavaScript and Node: part of
4
4
  [RealUptime Errors](https://realuptime.io/error-tracking).
5
5
 
6
- This is a **mirror**. It is published from `packages/errors-js` in the
6
+ This is a **mirror**. It is published from `errors-js` in the
7
7
  [realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
8
8
  `scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
9
9
  and PRs against this repo, but expect source changes to land here after
@@ -107,7 +107,7 @@ extra build step or type-stripping flag on the consumer's side.
107
107
 
108
108
  ## Version
109
109
 
110
- This mirror tracks SDK_VERSION `0.4.0` in `index.ts`, the string every
110
+ This mirror tracks SDK_VERSION `0.5.0` in `index.ts`, the string every
111
111
  event actually carries on the wire.
112
112
 
113
113
  ## License
package/client.ts CHANGED
@@ -3,7 +3,7 @@ import { MAX_EVENTS_PER_BATCH, type WireBatch, type WireEvent, type WireIngestRe
3
3
 
4
4
  /**
5
5
  * Delivery: batched, buffered, backed off, and incapable of crashing the
6
- * host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
6
+ * host app (the internal-docs repo's errors-plan.md, "The SDKs"). Disciplines inherited from
7
7
  * apps/agent/api.ts and runtime.ts:
8
8
  *
9
9
  * - Failures never throw out of the SDK. Everything is caught; the first
@@ -52,8 +52,16 @@ export class Transport {
52
52
  private loggedKinds = new Set<string>();
53
53
  private inFlight: Promise<void> | null = null;
54
54
 
55
- constructor(private readonly options: TransportOptions) {
56
- this.fetchImpl = options.fetchImpl ?? fetch;
55
+ // Explicit fields, not parameter properties: strip-only TS mode (the
56
+ // probe runtime) cannot execute parameter properties.
57
+ private readonly options: TransportOptions;
58
+ constructor(options: TransportOptions) {
59
+ this.options = options;
60
+ // A bare `fetch` reference called as `this.fetchImpl(...)` loses its
61
+ // window binding and throws "Illegal invocation" in every browser (Node
62
+ // does not care, which is why server-side use and the mocked tests never
63
+ // saw it). Bind explicitly.
64
+ this.fetchImpl = options.fetchImpl ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
57
65
  this.now = options.now ?? Date.now;
58
66
  this.log = options.log ?? ((message) => console.error(message));
59
67
  }
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type WireEvent } from "./types.ts";
2
2
  /**
3
3
  * Delivery: batched, buffered, backed off, and incapable of crashing the
4
- * host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
4
+ * host app (the internal-docs repo's errors-plan.md, "The SDKs"). Disciplines inherited from
5
5
  * apps/agent/api.ts and runtime.ts:
6
6
  *
7
7
  * - Failures never throw out of the SDK. Everything is caught; the first
@@ -30,7 +30,6 @@ export interface TransportOptions {
30
30
  log?: (message: string) => void;
31
31
  }
32
32
  export declare class Transport {
33
- private readonly options;
34
33
  private readonly buffer;
35
34
  private readonly fetchImpl;
36
35
  private readonly now;
@@ -41,6 +40,7 @@ export declare class Transport {
41
40
  private disabled;
42
41
  private loggedKinds;
43
42
  private inFlight;
43
+ private readonly options;
44
44
  constructor(options: TransportOptions);
45
45
  enqueue(event: WireEvent): void;
46
46
  /** Sends what is due. Serialized: one delivery in flight at a time, so a
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@ import { EventBuffer } from "./buffer.js";
2
2
  import { MAX_EVENTS_PER_BATCH } from "./types.js";
3
3
  /**
4
4
  * Delivery: batched, buffered, backed off, and incapable of crashing the
5
- * host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
5
+ * host app (the internal-docs repo's errors-plan.md, "The SDKs"). Disciplines inherited from
6
6
  * apps/agent/api.ts and runtime.ts:
7
7
  *
8
8
  * - Failures never throw out of the SDK. Everything is caught; the first
@@ -24,7 +24,6 @@ export const SEND_TIMEOUT_MS = 10_000;
24
24
  export const BACKOFF_START_MS = 5_000;
25
25
  export const BACKOFF_MAX_MS = 300_000;
26
26
  export class Transport {
27
- options;
28
27
  buffer = new EventBuffer();
29
28
  fetchImpl;
30
29
  now;
@@ -35,9 +34,16 @@ export class Transport {
35
34
  disabled = false;
36
35
  loggedKinds = new Set();
37
36
  inFlight = null;
37
+ // Explicit fields, not parameter properties: strip-only TS mode (the
38
+ // probe runtime) cannot execute parameter properties.
39
+ options;
38
40
  constructor(options) {
39
41
  this.options = options;
40
- this.fetchImpl = options.fetchImpl ?? fetch;
42
+ // A bare `fetch` reference called as `this.fetchImpl(...)` loses its
43
+ // window binding and throws "Illegal invocation" in every browser (Node
44
+ // does not care, which is why server-side use and the mocked tests never
45
+ // saw it). Bind explicitly.
46
+ this.fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
41
47
  this.now = options.now ?? Date.now;
42
48
  this.log = options.log ?? ((message) => console.error(message));
43
49
  }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { type FeedbackOptions, type ReportDialogHandle, type ReportDialogOptions } from "./feedback.ts";
2
2
  export type { FeedbackOptions, ReportDialogField, ReportDialogHandle, ReportDialogOptions, ReportDialogTheme } from "./feedback.ts";
3
+ export type { WireEvent } from "./types.ts";
3
4
  import { type CustomScrubRule } from "./custom-scrub.ts";
4
- import { type WireFrame, type WireRequest, type WireUser } from "./types.ts";
5
+ import { type ErrorLevel, type WireEvent, type WireFrame, type WireRequest, type WireUser } from "./types.ts";
5
6
  /**
6
7
  * @realuptime/errors: minimal JS/Node error tracking SDK, Phase 1
7
- * (docs/errors-plan.md). Captures unhandled exceptions and rejections plus
8
+ * (the internal-docs repo's errors-plan.md). Captures unhandled exceptions and rejections plus
8
9
  * manual `captureException`/`captureMessage`, scrubs PII client-side by
9
10
  * default (scrub.ts), and posts to the project's DSN-style ingest endpoint.
10
11
  *
@@ -14,13 +15,13 @@ import { type WireFrame, type WireRequest, type WireUser } from "./types.ts";
14
15
  * none.
15
16
  */
16
17
  export declare const SDK_NAME = "realuptime-errors-js";
17
- export declare const SDK_VERSION = "0.4.0";
18
+ export declare const SDK_VERSION = "0.5.0";
18
19
  /** How long the one-time rules fetch may take before the SDK stops waiting
19
20
  * for it (it was never blocking capture anyway). */
20
21
  export declare const SCRUB_RULES_TIMEOUT_MS = 5000;
21
22
  export interface ErrorsInit {
22
23
  /** The project's ingest URL, e.g.
23
- * `https://realuptime.io/api/errors/v1/ingest/rue_...`. Shown once at
24
+ * `https://ingest.realuptime.io/api/errors/v1/ingest/rue_...`. Shown once at
24
25
  * project creation; safe to embed client-side (it can only submit events
25
26
  * to its one project). */
26
27
  dsn: string;
@@ -59,11 +60,27 @@ export interface ErrorsInit {
59
60
  * switch in realuptime Errors settings must ALSO be on, or the endpoint
60
61
  * refuses with `reason: "feedback_disabled"`. See feedback.ts. */
61
62
  feedback?: FeedbackOptions;
63
+ /** A last look at every event before it is queued: return the event to
64
+ * send (the same object or a new one), or null to drop it. Runs AFTER the
65
+ * default scrub and any additional scrub rules, so it sees exactly what
66
+ * would otherwise leave and can only narrow it further. Fails closed: a
67
+ * hook that throws drops the event (logged once) rather than letting it
68
+ * through unfiltered, because a hook installed to remove data must never
69
+ * turn into the reason that data leaves. */
70
+ beforeSend?: (event: WireEvent) => WireEvent | null;
62
71
  /** Test seams. */
63
72
  fetchImpl?: typeof fetch;
64
73
  now?: () => number;
65
74
  log?: (message: string) => void;
66
75
  }
76
+ /**
77
+ * Whether error reporting is actually on: `init()` was called with a dsn
78
+ * that passed `isIngestUrl`. A host process should log this once at
79
+ * startup (`realuptimeErrors.isEnabled()`) rather than assume that wiring
80
+ * `init()` into the code means events are flowing -- REA-575 was exactly
81
+ * that assumption, wrong for months because nothing checked it.
82
+ */
83
+ export declare function isEnabled(): boolean;
67
84
  /** Parses a V8/JavaScriptCore/SpiderMonkey stack string into wire frames,
68
85
  * innermost first. Best-effort: an unparseable line is skipped, never
69
86
  * fatal. */
@@ -79,6 +96,9 @@ export interface CaptureContext {
79
96
  tags?: Record<string, string>;
80
97
  /** v2: custom context for THIS event only, merged over the sticky scope. */
81
98
  context?: Record<string, string>;
99
+ /** REA-569: severity for THIS event only. No sticky/init() equivalent --
100
+ * see WireEvent.level's doc for why. */
101
+ level?: ErrorLevel;
82
102
  }
83
103
  /**
84
104
  * v2 (REA-182): sets the sticky identity applied to every subsequent event.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { applyCustomScrubRules, compileCustomScrubRules, NO_CUSTOM_SCRUB_RULES,
5
5
  import { MAX_BREADCRUMB_DATA_ENTRIES, MAX_BREADCRUMBS_PER_EVENT, MAX_CONTEXT_ENTRIES, MAX_CONTEXT_KEY_LENGTH, MAX_FRAMES_PER_EVENT, MAX_MESSAGE_LENGTH, MAX_STRING_LENGTH, MAX_TAGS_PER_EVENT, } from "./types.js";
6
6
  /**
7
7
  * @realuptime/errors: minimal JS/Node error tracking SDK, Phase 1
8
- * (docs/errors-plan.md). Captures unhandled exceptions and rejections plus
8
+ * (the internal-docs repo's errors-plan.md). Captures unhandled exceptions and rejections plus
9
9
  * manual `captureException`/`captureMessage`, scrubs PII client-side by
10
10
  * default (scrub.ts), and posts to the project's DSN-style ingest endpoint.
11
11
  *
@@ -15,12 +15,13 @@ import { MAX_BREADCRUMB_DATA_ENTRIES, MAX_BREADCRUMBS_PER_EVENT, MAX_CONTEXT_ENT
15
15
  * none.
16
16
  */
17
17
  export const SDK_NAME = "realuptime-errors-js";
18
- export const SDK_VERSION = "0.4.0";
18
+ export const SDK_VERSION = "0.5.0";
19
19
  /** How long the one-time rules fetch may take before the SDK stops waiting
20
20
  * for it (it was never blocking capture anyway). */
21
21
  export const SCRUB_RULES_TIMEOUT_MS = 5_000;
22
22
  let state = null;
23
23
  let initFailureLogged = false;
24
+ let beforeSendFailureLogged = false;
24
25
  function safeLog(message) {
25
26
  try {
26
27
  (state?.options.log ?? console.error)(`[realuptime-errors] ${message}`);
@@ -29,6 +30,41 @@ function safeLog(message) {
29
30
  // Even logging must not throw.
30
31
  }
31
32
  }
33
+ /** The ingest route's path shape: `/api/errors/v1/ingest/<key>`, an
34
+ * optional trailing slash, nothing else. Mirrors the marker check in
35
+ * feedback.ts/custom-scrub.ts and the stricter parse in sourcemaps.ts. */
36
+ const INGEST_PATH_RE = /^\/api\/errors\/v1\/ingest\/[^/?#]+\/?$/;
37
+ /**
38
+ * REA-575: a `dsn` must be the full ingest URL the server hands out at
39
+ * project creation, not the bare `rue_...` key alone. Before this check,
40
+ * `init()` accepted any non-empty string and handed it straight to the
41
+ * transport as the fetch endpoint: a bare key is not a fetchable URL, so
42
+ * every send failed inside the transport's own never-throw try/catch and
43
+ * the SDK silently reported nothing, forever, with no signal anywhere
44
+ * (realuptime-web's own DSN held exactly this mistake in production).
45
+ */
46
+ function isIngestUrl(dsn) {
47
+ let url;
48
+ try {
49
+ url = new URL(dsn);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (url.protocol !== "http:" && url.protocol !== "https:")
55
+ return false;
56
+ return INGEST_PATH_RE.test(url.pathname);
57
+ }
58
+ /**
59
+ * Whether error reporting is actually on: `init()` was called with a dsn
60
+ * that passed `isIngestUrl`. A host process should log this once at
61
+ * startup (`realuptimeErrors.isEnabled()`) rather than assume that wiring
62
+ * `init()` into the code means events are flowing -- REA-575 was exactly
63
+ * that assumption, wrong for months because nothing checked it.
64
+ */
65
+ export function isEnabled() {
66
+ return state !== null;
67
+ }
32
68
  function clip(value, max) {
33
69
  return value.length > max ? value.slice(0, max) : value;
34
70
  }
@@ -240,6 +276,7 @@ function buildEvent(message, exceptionType, stack, context) {
240
276
  // REA-257: minted only when feedback is enabled, so an integration that
241
277
  // never opted in keeps emitting the id-less payload it always did.
242
278
  ...(state?.feedback ? { eventId: mintEventId() } : {}),
279
+ ...(context.level ? { level: context.level } : {}),
243
280
  };
244
281
  }
245
282
  /**
@@ -281,10 +318,30 @@ function capture(event) {
281
318
  return null;
282
319
  // Defaults first, always; the customer's additional rules on the result.
283
320
  const scrubbed = scrubEvent(event, state.options.allowFields ?? []);
284
- state.transport.enqueue(applyCustomScrubRules(scrubbed, state.customScrub));
285
- if (event.eventId)
286
- state.lastEventId = event.eventId;
287
- return event.eventId ?? null;
321
+ const outgoing = runBeforeSend(applyCustomScrubRules(scrubbed, state.customScrub), state.options.beforeSend);
322
+ if (!outgoing)
323
+ return null;
324
+ state.transport.enqueue(outgoing);
325
+ if (outgoing.eventId)
326
+ state.lastEventId = outgoing.eventId;
327
+ return outgoing.eventId ?? null;
328
+ }
329
+ /** Applies the integrator's `beforeSend`, failing closed: a throw or a
330
+ * non-object result drops the event. */
331
+ function runBeforeSend(event, hook) {
332
+ if (typeof hook !== "function")
333
+ return event;
334
+ try {
335
+ const result = hook(event);
336
+ return result && typeof result === "object" ? result : null;
337
+ }
338
+ catch (failure) {
339
+ if (!beforeSendFailureLogged) {
340
+ beforeSendFailureLogged = true;
341
+ safeLog(`beforeSend threw; the event was dropped: ${String(failure)}`);
342
+ }
343
+ return null;
344
+ }
288
345
  }
289
346
  /** Fetches the project's additional scrub rules by ingest key and merges
290
347
  * them over the inline ones. Best-effort, bounded, never throws, never
@@ -295,7 +352,7 @@ async function fetchScrubRules(forState) {
295
352
  const url = scrubRulesUrlFor(forState.options.dsn);
296
353
  if (!url)
297
354
  return;
298
- const fetchImpl = forState.options.fetchImpl ?? fetch;
355
+ const fetchImpl = forState.options.fetchImpl ?? ((...args) => fetch(...args));
299
356
  const response = await fetchImpl(url, { method: "GET", signal: AbortSignal.timeout(SCRUB_RULES_TIMEOUT_MS) });
300
357
  if (!response.ok)
301
358
  return;
@@ -428,6 +485,19 @@ function installUnhandledHandlers() {
428
485
  }
429
486
  return null;
430
487
  }
488
+ /** Logs an init-time refusal through THIS call's own `log` option (falling
489
+ * back to console.error), not through whatever a previous successful init
490
+ * left in `state` -- state is intentionally untouched on a refused init, so
491
+ * using `state?.options.log` here would mean a caller's own logger never
492
+ * sees why its own call was refused. */
493
+ function logInitFailure(options, message) {
494
+ try {
495
+ (options?.log ?? console.error)(`[realuptime-errors] ${message}`);
496
+ }
497
+ catch {
498
+ // Even logging must not throw.
499
+ }
500
+ }
431
501
  /** Initializes the SDK. Safe to call more than once (the last call wins);
432
502
  * safe to call with a bad DSN (logs once, stays inert). */
433
503
  export function init(options) {
@@ -435,7 +505,14 @@ export function init(options) {
435
505
  if (!options?.dsn || typeof options.dsn !== "string") {
436
506
  if (!initFailureLogged) {
437
507
  initFailureLogged = true;
438
- safeLog("init called without a dsn; error reporting is disabled.");
508
+ logInitFailure(options, "init called without a dsn; error reporting is disabled.");
509
+ }
510
+ return;
511
+ }
512
+ if (!isIngestUrl(options.dsn)) {
513
+ if (!initFailureLogged) {
514
+ initFailureLogged = true;
515
+ logInitFailure(options, "dsn must be the full ingest URL, got a bare key (or a malformed URL); expected https://<host>/api/errors/v1/ingest/<key>. Error reporting is disabled.");
439
516
  }
440
517
  return;
441
518
  }
package/dist/scrub.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { WireEvent } from "./types.ts";
2
2
  /**
3
- * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
3
+ * PII scrub-by-default, client-side (the internal-docs repo's errors-plan.md, "PII
4
4
  * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
5
5
  * value never transits realuptime's wire; the server runs the same rules
6
6
  * again as a second net (packages/db/error-scrub.ts), and the shared
@@ -9,15 +9,32 @@ import type { WireEvent } from "./types.ts";
9
9
  *
10
10
  * The rules, in the order they run over every scrubbed string:
11
11
  *
12
+ * 0. URL userinfo credentials (`scheme://user:pass@host`, REA-474): the
13
+ * userinfo is replaced whole; the host stays readable. Runs first so a
14
+ * password that happens to look hex- or base64-shaped is not partially
15
+ * eaten by a later rule before the URL rule can see the whole userinfo.
12
16
  * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
13
- * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
14
- * followed by 8+ token characters.
17
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_/
18
+ * whsec_ (REA-474) followed by 8+ token characters.
19
+ * 2b. AWS access-key ids (REA-474): AKIA/ASIA followed by exactly 16
20
+ * uppercase-alnum characters.
15
21
  * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
16
22
  * separators, passing Luhn. A digit run that fails Luhn is an order
17
23
  * id, not a card, and stays.
18
24
  * 4. Long hex runs (32+).
19
25
  * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
20
26
  *
27
+ * BEFORE any of the above, a KEY-NAME denylist pass (REA-474) runs over
28
+ * every flat string map (tags, custom context, device, breadcrumb data,
29
+ * stack-frame locals) and over request headers: a key whose name -- read as
30
+ * whole snake_case/camelCase segments, so "tokenizer" and "authentic" do
31
+ * NOT match "token" and "auth" -- is password, passwd, secret, token,
32
+ * api_key/apikey, auth, authorization, credential(s), private_key,
33
+ * access_key, or session, has its value replaced whole regardless of shape.
34
+ * For headers this is folded into the existing REMOVED_HEADERS check (same
35
+ * allowFields opt-back-in); for the other maps it is unconditional, the
36
+ * same as the maps' key set itself.
37
+ *
21
38
  * Scrubbed strings: the message, every request header value, the request
22
39
  * path and route, every breadcrumb message and breadcrumb data value
23
40
  * (Phase 3), any source-context lines a frame carries (a hardcoded secret
@@ -48,6 +65,11 @@ export declare const SCRUBBED = "[scrubbed]";
48
65
  /** Identity fields removed whole unless allow-listed by these exact names.
49
66
  * `user.id` is deliberately absent: see the module doc. */
50
67
  export declare const REMOVED_USER_FIELDS: readonly ["user.email", "user.username"];
68
+ /** True when some contiguous run of a key's segments, joined with no
69
+ * separator, equals a denylisted word exactly -- catches "api_key",
70
+ * "apiKey" and "db_password" alike, without matching "tokenizer" or
71
+ * "authentic". */
72
+ export declare function isKeyDenylisted(key: string): boolean;
51
73
  /** Applies the five pattern rules to one string. Pure; exported so the
52
74
  * vector tests can drive it directly. */
53
75
  export declare function scrubString(value: string): string;
package/dist/scrub.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
2
+ * PII scrub-by-default, client-side (the internal-docs repo's errors-plan.md, "PII
3
3
  * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
4
4
  * value never transits realuptime's wire; the server runs the same rules
5
5
  * again as a second net (packages/db/error-scrub.ts), and the shared
@@ -8,15 +8,32 @@
8
8
  *
9
9
  * The rules, in the order they run over every scrubbed string:
10
10
  *
11
+ * 0. URL userinfo credentials (`scheme://user:pass@host`, REA-474): the
12
+ * userinfo is replaced whole; the host stays readable. Runs first so a
13
+ * password that happens to look hex- or base64-shaped is not partially
14
+ * eaten by a later rule before the URL rule can see the whole userinfo.
11
15
  * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
12
- * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
13
- * followed by 8+ token characters.
16
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_/
17
+ * whsec_ (REA-474) followed by 8+ token characters.
18
+ * 2b. AWS access-key ids (REA-474): AKIA/ASIA followed by exactly 16
19
+ * uppercase-alnum characters.
14
20
  * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
15
21
  * separators, passing Luhn. A digit run that fails Luhn is an order
16
22
  * id, not a card, and stays.
17
23
  * 4. Long hex runs (32+).
18
24
  * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
19
25
  *
26
+ * BEFORE any of the above, a KEY-NAME denylist pass (REA-474) runs over
27
+ * every flat string map (tags, custom context, device, breadcrumb data,
28
+ * stack-frame locals) and over request headers: a key whose name -- read as
29
+ * whole snake_case/camelCase segments, so "tokenizer" and "authentic" do
30
+ * NOT match "token" and "auth" -- is password, passwd, secret, token,
31
+ * api_key/apikey, auth, authorization, credential(s), private_key,
32
+ * access_key, or session, has its value replaced whole regardless of shape.
33
+ * For headers this is folded into the existing REMOVED_HEADERS check (same
34
+ * allowFields opt-back-in); for the other maps it is unconditional, the
35
+ * same as the maps' key set itself.
36
+ *
20
37
  * Scrubbed strings: the message, every request header value, the request
21
38
  * path and route, every breadcrumb message and breadcrumb data value
22
39
  * (Phase 3), any source-context lines a frame carries (a hardcoded secret
@@ -49,11 +66,57 @@ const REMOVED_HEADERS = new Set(["authorization", "proxy-authorization", "cookie
49
66
  /** Identity fields removed whole unless allow-listed by these exact names.
50
67
  * `user.id` is deliberately absent: see the module doc. */
51
68
  export const REMOVED_USER_FIELDS = ["user.email", "user.username"];
69
+ const URL_CREDS_RE = /\b([a-zA-Z][a-zA-Z0-9+.-]{0,31}):\/\/[^\s/@:]+:[^\s/@]+@/g;
52
70
  const JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
53
- const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/g;
71
+ const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live|whsec)_[A-Za-z0-9_-]{8,}/g;
72
+ const AWS_KEY_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
54
73
  const CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
55
74
  const HEX_RE = /\b[0-9a-fA-F]{32,}\b/g;
56
75
  const BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/g;
76
+ /** Key-name denylist (REA-474): normalized (lowercased, separator-joined)
77
+ * segments that mark a field as sensitive by name alone, regardless of what
78
+ * its value looks like. Kept as single normalized words/compounds so a
79
+ * whole-segment match never fires on a substring -- "tokenizer" is one
80
+ * segment, "tokenizer", not "token" plus a suffix. */
81
+ const KEY_DENYLIST = new Set([
82
+ "password",
83
+ "passwd",
84
+ "secret",
85
+ "token",
86
+ "apikey",
87
+ "auth",
88
+ "authorization",
89
+ "credential",
90
+ "credentials",
91
+ "privatekey",
92
+ "accesskey",
93
+ "session",
94
+ ]);
95
+ /** Splits a key into lowercase segments on camelCase boundaries and any
96
+ * non-alphanumeric separator (snake_case, kebab-case, dotted). */
97
+ function keySegments(key) {
98
+ return key
99
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
100
+ .split(/[^a-zA-Z0-9]+/)
101
+ .filter(Boolean)
102
+ .map((s) => s.toLowerCase());
103
+ }
104
+ /** True when some contiguous run of a key's segments, joined with no
105
+ * separator, equals a denylisted word exactly -- catches "api_key",
106
+ * "apiKey" and "db_password" alike, without matching "tokenizer" or
107
+ * "authentic". */
108
+ export function isKeyDenylisted(key) {
109
+ const segments = keySegments(key);
110
+ for (let i = 0; i < segments.length; i++) {
111
+ let joined = "";
112
+ for (let j = i; j < segments.length; j++) {
113
+ joined += segments[j];
114
+ if (KEY_DENYLIST.has(joined))
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
57
120
  function luhnValid(digits) {
58
121
  let sum = 0;
59
122
  let double = false;
@@ -72,8 +135,10 @@ function luhnValid(digits) {
72
135
  /** Applies the five pattern rules to one string. Pure; exported so the
73
136
  * vector tests can drive it directly. */
74
137
  export function scrubString(value) {
75
- let out = value.replace(JWT_RE, SCRUBBED);
138
+ let out = value.replace(URL_CREDS_RE, (_match, scheme) => `${scheme}://${SCRUBBED}@`);
139
+ out = out.replace(JWT_RE, SCRUBBED);
76
140
  out = out.replace(PREFIXED_KEY_RE, SCRUBBED);
141
+ out = out.replace(AWS_KEY_RE, SCRUBBED);
77
142
  out = out.replace(CARD_RE, (run) => {
78
143
  const digits = run.replace(/[ -]/g, "");
79
144
  return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? SCRUBBED : run;
@@ -92,7 +157,7 @@ function scrubRequest(request, allowFields) {
92
157
  const headers = {};
93
158
  for (const [name, value] of Object.entries(out.headers)) {
94
159
  const lower = name.toLowerCase();
95
- if (REMOVED_HEADERS.has(lower) && !allowFields.has(lower)) {
160
+ if ((REMOVED_HEADERS.has(lower) || isKeyDenylisted(lower)) && !allowFields.has(lower)) {
96
161
  headers[name] = SCRUBBED;
97
162
  }
98
163
  else {
@@ -107,8 +172,9 @@ function scrubBreadcrumb(crumb) {
107
172
  let data = null;
108
173
  if (crumb.data) {
109
174
  data = {};
110
- for (const [name, value] of Object.entries(crumb.data))
111
- data[name] = scrubString(value);
175
+ for (const [name, value] of Object.entries(crumb.data)) {
176
+ data[name] = isKeyDenylisted(name) ? SCRUBBED : scrubString(value);
177
+ }
112
178
  }
113
179
  return { ...crumb, message: scrubString(crumb.message), data };
114
180
  }
@@ -133,7 +199,12 @@ function scrubUser(user, allowFields) {
133
199
  function scrubStringMap(map) {
134
200
  const out = {};
135
201
  for (const [name, value] of Object.entries(map)) {
136
- out[name] = typeof value === "string" ? scrubString(value) : value;
202
+ if (typeof value !== "string") {
203
+ out[name] = value;
204
+ }
205
+ else {
206
+ out[name] = isKeyDenylisted(name) ? SCRUBBED : scrubString(value);
207
+ }
137
208
  }
138
209
  return out;
139
210
  }
@@ -9,6 +9,12 @@
9
9
  * secret a CI step needs is the DSN the app already has; this module derives
10
10
  * the upload URL from that DSN (or from a bare key plus an origin).
11
11
  *
12
+ * Only the main host serves that route. A DSN issued since the hostname split
13
+ * names the intake host, which takes error events and nothing as heavy as an
14
+ * upload (up to 10 files of 2 MB each), so a DSN on the intake host uploads to
15
+ * the main host. The intake answers an upload that still arrives there with a
16
+ * redirect to the main host (apps/ingest), for clients published before this.
17
+ *
12
18
  * Honesty rules, same as the rest of the SDK:
13
19
  * - Local refusals happen BEFORE any byte is sent (a file over the per-file
14
20
  * cap, more files than a release may hold, no `.map` files found), and
@@ -31,6 +37,10 @@ export declare const MAX_SOURCEMAP_BYTES: number;
31
37
  export declare const MAX_ARTIFACTS_PER_RELEASE = 50;
32
38
  export declare const MAX_ARTIFACTS_PER_UPLOAD = 10;
33
39
  export declare const DEFAULT_API_ORIGIN = "https://realuptime.io";
40
+ /** The intake host every DSN issued since the hostname split names
41
+ * (`ingestBaseUrl` in packages/db/errors-dsn.ts). Source maps are not
42
+ * uploaded there; see the module comment. */
43
+ export declare const INGEST_HOSTNAME = "ingest.realuptime.io";
34
44
  export declare class SourcemapUploadError extends Error {
35
45
  /** Non-2xx answers carry the server's own status and RU- code verbatim. */
36
46
  readonly httpStatus?: number;
@@ -41,7 +51,7 @@ export declare class SourcemapUploadError extends Error {
41
51
  });
42
52
  }
43
53
  export interface UploadTarget {
44
- /** The project DSN, e.g. `https://realuptime.io/api/errors/v1/ingest/rue_...`. */
54
+ /** The project DSN, e.g. `https://ingest.realuptime.io/api/errors/v1/ingest/rue_...`. */
45
55
  dsn?: string;
46
56
  /** A bare ingest key, used with `origin` when no DSN is at hand. */
47
57
  ingestKey?: string;
@@ -49,8 +59,12 @@ export interface UploadTarget {
49
59
  origin?: string;
50
60
  }
51
61
  /**
52
- * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
53
- * A DSN wins over a bare key when both are given; either is enough.
62
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`,
63
+ * except that a DSN on the intake host (`ingest.realuptime.io`) uploads to the
64
+ * main host, `https://realuptime.io`, because only the main host stores maps.
65
+ * Any other DSN host (a pre-split `realuptime.io` DSN, a self-hosted or local
66
+ * one) keeps its own origin. A DSN wins over a bare key when both are given;
67
+ * either is enough.
54
68
  */
55
69
  export declare function resolveSourcemapUploadUrl(target: UploadTarget): string;
56
70
  export interface SourcemapFile {
@@ -12,6 +12,12 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
12
12
  * secret a CI step needs is the DSN the app already has; this module derives
13
13
  * the upload URL from that DSN (or from a bare key plus an origin).
14
14
  *
15
+ * Only the main host serves that route. A DSN issued since the hostname split
16
+ * names the intake host, which takes error events and nothing as heavy as an
17
+ * upload (up to 10 files of 2 MB each), so a DSN on the intake host uploads to
18
+ * the main host. The intake answers an upload that still arrives there with a
19
+ * redirect to the main host (apps/ingest), for clients published before this.
20
+ *
15
21
  * Honesty rules, same as the rest of the SDK:
16
22
  * - Local refusals happen BEFORE any byte is sent (a file over the per-file
17
23
  * cap, more files than a release may hold, no `.map` files found), and
@@ -34,6 +40,10 @@ export const MAX_SOURCEMAP_BYTES = 2 * 1024 * 1024;
34
40
  export const MAX_ARTIFACTS_PER_RELEASE = 50;
35
41
  export const MAX_ARTIFACTS_PER_UPLOAD = 10;
36
42
  export const DEFAULT_API_ORIGIN = "https://realuptime.io";
43
+ /** The intake host every DSN issued since the hostname split names
44
+ * (`ingestBaseUrl` in packages/db/errors-dsn.ts). Source maps are not
45
+ * uploaded there; see the module comment. */
46
+ export const INGEST_HOSTNAME = "ingest.realuptime.io";
37
47
  const INGEST_KEY_SHAPE = /^rue_[0-9a-f]{32,128}$/;
38
48
  export class SourcemapUploadError extends Error {
39
49
  /** Non-2xx answers carry the server's own status and RU- code verbatim. */
@@ -47,8 +57,12 @@ export class SourcemapUploadError extends Error {
47
57
  }
48
58
  }
49
59
  /**
50
- * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
51
- * A DSN wins over a bare key when both are given; either is enough.
60
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`,
61
+ * except that a DSN on the intake host (`ingest.realuptime.io`) uploads to the
62
+ * main host, `https://realuptime.io`, because only the main host stores maps.
63
+ * Any other DSN host (a pre-split `realuptime.io` DSN, a self-hosted or local
64
+ * one) keeps its own origin. A DSN wins over a bare key when both are given;
65
+ * either is enough.
52
66
  */
53
67
  export function resolveSourcemapUploadUrl(target) {
54
68
  if (target.dsn) {
@@ -63,7 +77,8 @@ export function resolveSourcemapUploadUrl(target) {
63
77
  if (!match) {
64
78
  throw new SourcemapUploadError("The DSN does not look like a realuptime errors DSN (expected .../api/errors/v1/ingest/rue_...).");
65
79
  }
66
- return `${url.origin}${match[1]}/sourcemaps/${match[2]}`;
80
+ const origin = url.hostname === INGEST_HOSTNAME ? DEFAULT_API_ORIGIN : url.origin;
81
+ return `${origin}${match[1]}/sourcemaps/${match[2]}`;
67
82
  }
68
83
  if (target.ingestKey) {
69
84
  if (!INGEST_KEY_SHAPE.test(target.ingestKey)) {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Errors wire contract (docs/errors-plan.md, "The SDKs: two
2
+ * The Errors wire contract (the internal-docs repo's errors-plan.md, "The SDKs: two
3
3
  * languages, one contract").
4
4
  *
5
5
  * DUPLICATED, not imported. Same convention as apps/agent/types.ts and for
@@ -170,7 +170,16 @@ export interface WireEvent {
170
170
  * user's report to this exact occurrence. An integration that never
171
171
  * enables feedback keeps emitting the id-less payload it always did. */
172
172
  eventId?: string;
173
+ /** REA-569: severity, Sentry's five-value convention. Per-capture only
174
+ * (CaptureContext.level, not a sticky init()/scope option): unlike
175
+ * environment or release, a severity is a fact about THIS occurrence, not
176
+ * about the process. Absent resolves to "error" server-side. */
177
+ level?: ErrorLevel;
173
178
  }
179
+ /** REA-569: the closed severity set, Sentry's own five values so a
180
+ * migrating customer's rule filters translate directly. */
181
+ export type ErrorLevel = "fatal" | "error" | "warning" | "info" | "debug";
182
+ export declare const ERROR_LEVELS: readonly ErrorLevel[];
174
183
  export interface WireBatch {
175
184
  /** e.g. "realuptime-errors-js/0.1.0" */
176
185
  sdk: string;
@@ -240,7 +249,7 @@ export interface WireFeedback {
240
249
  * filled it. Scrubbed by the same pattern rules. */
241
250
  name?: string;
242
251
  email?: string;
243
- /** e.g. "realuptime-errors-js/0.4.0" */
252
+ /** e.g. "realuptime-errors-js/0.5.0" */
244
253
  sdk: string;
245
254
  }
246
255
  export declare const MAX_FEEDBACK_COMMENTS_LENGTH = 4000;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Errors wire contract (docs/errors-plan.md, "The SDKs: two
2
+ * The Errors wire contract (the internal-docs repo's errors-plan.md, "The SDKs: two
3
3
  * languages, one contract").
4
4
  *
5
5
  * DUPLICATED, not imported. Same convention as apps/agent/types.ts and for
@@ -31,6 +31,7 @@
31
31
  * (a removed field, a changed meaning) gets a versioned PATH, the way the
32
32
  * fingerprint algorithm gets a version rather than mutating history.
33
33
  */
34
+ export const ERROR_LEVELS = ["fatal", "error", "warning", "info", "debug"];
34
35
  // Mirrored by MAX_ERROR_EVENTS_PER_BATCH etc. in packages/db/error-ingest.ts
35
36
  // and pinned by wire-contract.test.ts on both sides.
36
37
  export const MAX_EVENTS_PER_BATCH = 50;
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./feedback.ts";
15
15
  import { scrubEvent } from "./scrub.ts";
16
16
  export type { FeedbackOptions, ReportDialogField, ReportDialogHandle, ReportDialogOptions, ReportDialogTheme } from "./feedback.ts";
17
+ export type { WireEvent } from "./types.ts";
17
18
  import {
18
19
  applyCustomScrubRules,
19
20
  type CompiledCustomScrubRules,
@@ -31,6 +32,7 @@ import {
31
32
  MAX_MESSAGE_LENGTH,
32
33
  MAX_STRING_LENGTH,
33
34
  MAX_TAGS_PER_EVENT,
35
+ type ErrorLevel,
34
36
  type WireBreadcrumb,
35
37
  type WireDevice,
36
38
  type WireEvent,
@@ -41,7 +43,7 @@ import {
41
43
 
42
44
  /**
43
45
  * @realuptime/errors: minimal JS/Node error tracking SDK, Phase 1
44
- * (docs/errors-plan.md). Captures unhandled exceptions and rejections plus
46
+ * (the internal-docs repo's errors-plan.md). Captures unhandled exceptions and rejections plus
45
47
  * manual `captureException`/`captureMessage`, scrubs PII client-side by
46
48
  * default (scrub.ts), and posts to the project's DSN-style ingest endpoint.
47
49
  *
@@ -52,14 +54,14 @@ import {
52
54
  */
53
55
 
54
56
  export const SDK_NAME = "realuptime-errors-js";
55
- export const SDK_VERSION = "0.4.0";
57
+ export const SDK_VERSION = "0.5.0";
56
58
  /** How long the one-time rules fetch may take before the SDK stops waiting
57
59
  * for it (it was never blocking capture anyway). */
58
60
  export const SCRUB_RULES_TIMEOUT_MS = 5_000;
59
61
 
60
62
  export interface ErrorsInit {
61
63
  /** The project's ingest URL, e.g.
62
- * `https://realuptime.io/api/errors/v1/ingest/rue_...`. Shown once at
64
+ * `https://ingest.realuptime.io/api/errors/v1/ingest/rue_...`. Shown once at
63
65
  * project creation; safe to embed client-side (it can only submit events
64
66
  * to its one project). */
65
67
  dsn: string;
@@ -98,6 +100,14 @@ export interface ErrorsInit {
98
100
  * switch in realuptime Errors settings must ALSO be on, or the endpoint
99
101
  * refuses with `reason: "feedback_disabled"`. See feedback.ts. */
100
102
  feedback?: FeedbackOptions;
103
+ /** A last look at every event before it is queued: return the event to
104
+ * send (the same object or a new one), or null to drop it. Runs AFTER the
105
+ * default scrub and any additional scrub rules, so it sees exactly what
106
+ * would otherwise leave and can only narrow it further. Fails closed: a
107
+ * hook that throws drops the event (logged once) rather than letting it
108
+ * through unfiltered, because a hook installed to remove data must never
109
+ * turn into the reason that data leaves. */
110
+ beforeSend?: (event: WireEvent) => WireEvent | null;
101
111
  /** Test seams. */
102
112
  fetchImpl?: typeof fetch;
103
113
  now?: () => number;
@@ -131,6 +141,7 @@ interface SdkState {
131
141
 
132
142
  let state: SdkState | null = null;
133
143
  let initFailureLogged = false;
144
+ let beforeSendFailureLogged = false;
134
145
 
135
146
  function safeLog(message: string): void {
136
147
  try {
@@ -140,6 +151,42 @@ function safeLog(message: string): void {
140
151
  }
141
152
  }
142
153
 
154
+ /** The ingest route's path shape: `/api/errors/v1/ingest/<key>`, an
155
+ * optional trailing slash, nothing else. Mirrors the marker check in
156
+ * feedback.ts/custom-scrub.ts and the stricter parse in sourcemaps.ts. */
157
+ const INGEST_PATH_RE = /^\/api\/errors\/v1\/ingest\/[^/?#]+\/?$/;
158
+
159
+ /**
160
+ * REA-575: a `dsn` must be the full ingest URL the server hands out at
161
+ * project creation, not the bare `rue_...` key alone. Before this check,
162
+ * `init()` accepted any non-empty string and handed it straight to the
163
+ * transport as the fetch endpoint: a bare key is not a fetchable URL, so
164
+ * every send failed inside the transport's own never-throw try/catch and
165
+ * the SDK silently reported nothing, forever, with no signal anywhere
166
+ * (realuptime-web's own DSN held exactly this mistake in production).
167
+ */
168
+ function isIngestUrl(dsn: string): boolean {
169
+ let url: URL;
170
+ try {
171
+ url = new URL(dsn);
172
+ } catch {
173
+ return false;
174
+ }
175
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
176
+ return INGEST_PATH_RE.test(url.pathname);
177
+ }
178
+
179
+ /**
180
+ * Whether error reporting is actually on: `init()` was called with a dsn
181
+ * that passed `isIngestUrl`. A host process should log this once at
182
+ * startup (`realuptimeErrors.isEnabled()`) rather than assume that wiring
183
+ * `init()` into the code means events are flowing -- REA-575 was exactly
184
+ * that assumption, wrong for months because nothing checked it.
185
+ */
186
+ export function isEnabled(): boolean {
187
+ return state !== null;
188
+ }
189
+
143
190
  function clip(value: string, max: number): string {
144
191
  return value.length > max ? value.slice(0, max) : value;
145
192
  }
@@ -186,6 +233,9 @@ export interface CaptureContext {
186
233
  tags?: Record<string, string>;
187
234
  /** v2: custom context for THIS event only, merged over the sticky scope. */
188
235
  context?: Record<string, string>;
236
+ /** REA-569: severity for THIS event only. No sticky/init() equivalent --
237
+ * see WireEvent.level's doc for why. */
238
+ level?: ErrorLevel;
189
239
  }
190
240
 
191
241
  /** Bounds one caller-supplied string map: keys and values clipped, entries
@@ -345,6 +395,7 @@ function buildEvent(message: string, exceptionType: string | null, stack: string
345
395
  // REA-257: minted only when feedback is enabled, so an integration that
346
396
  // never opted in keeps emitting the id-less payload it always did.
347
397
  ...(state?.feedback ? { eventId: mintEventId() } : {}),
398
+ ...(context.level ? { level: context.level } : {}),
348
399
  };
349
400
  }
350
401
 
@@ -391,9 +442,27 @@ function capture(event: WireEvent): string | null {
391
442
  if (!state) return null;
392
443
  // Defaults first, always; the customer's additional rules on the result.
393
444
  const scrubbed = scrubEvent(event, state.options.allowFields ?? []);
394
- state.transport.enqueue(applyCustomScrubRules(scrubbed, state.customScrub));
395
- if (event.eventId) state.lastEventId = event.eventId;
396
- return event.eventId ?? null;
445
+ const outgoing = runBeforeSend(applyCustomScrubRules(scrubbed, state.customScrub), state.options.beforeSend);
446
+ if (!outgoing) return null;
447
+ state.transport.enqueue(outgoing);
448
+ if (outgoing.eventId) state.lastEventId = outgoing.eventId;
449
+ return outgoing.eventId ?? null;
450
+ }
451
+
452
+ /** Applies the integrator's `beforeSend`, failing closed: a throw or a
453
+ * non-object result drops the event. */
454
+ function runBeforeSend(event: WireEvent, hook: ErrorsInit["beforeSend"]): WireEvent | null {
455
+ if (typeof hook !== "function") return event;
456
+ try {
457
+ const result = hook(event);
458
+ return result && typeof result === "object" ? result : null;
459
+ } catch (failure) {
460
+ if (!beforeSendFailureLogged) {
461
+ beforeSendFailureLogged = true;
462
+ safeLog(`beforeSend threw; the event was dropped: ${String(failure)}`);
463
+ }
464
+ return null;
465
+ }
397
466
  }
398
467
 
399
468
  /** Fetches the project's additional scrub rules by ingest key and merges
@@ -404,7 +473,7 @@ async function fetchScrubRules(forState: SdkState): Promise<void> {
404
473
  try {
405
474
  const url = scrubRulesUrlFor(forState.options.dsn);
406
475
  if (!url) return;
407
- const fetchImpl = forState.options.fetchImpl ?? fetch;
476
+ const fetchImpl = forState.options.fetchImpl ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
408
477
  const response = await fetchImpl(url, { method: "GET", signal: AbortSignal.timeout(SCRUB_RULES_TIMEOUT_MS) });
409
478
  if (!response.ok) return;
410
479
  const body = (await response.json()) as { rules?: unknown };
@@ -533,6 +602,19 @@ function installUnhandledHandlers(): (() => void) | null {
533
602
  return null;
534
603
  }
535
604
 
605
+ /** Logs an init-time refusal through THIS call's own `log` option (falling
606
+ * back to console.error), not through whatever a previous successful init
607
+ * left in `state` -- state is intentionally untouched on a refused init, so
608
+ * using `state?.options.log` here would mean a caller's own logger never
609
+ * sees why its own call was refused. */
610
+ function logInitFailure(options: ErrorsInit | undefined, message: string): void {
611
+ try {
612
+ (options?.log ?? console.error)(`[realuptime-errors] ${message}`);
613
+ } catch {
614
+ // Even logging must not throw.
615
+ }
616
+ }
617
+
536
618
  /** Initializes the SDK. Safe to call more than once (the last call wins);
537
619
  * safe to call with a bad DSN (logs once, stays inert). */
538
620
  export function init(options: ErrorsInit): void {
@@ -540,7 +622,17 @@ export function init(options: ErrorsInit): void {
540
622
  if (!options?.dsn || typeof options.dsn !== "string") {
541
623
  if (!initFailureLogged) {
542
624
  initFailureLogged = true;
543
- safeLog("init called without a dsn; error reporting is disabled.");
625
+ logInitFailure(options, "init called without a dsn; error reporting is disabled.");
626
+ }
627
+ return;
628
+ }
629
+ if (!isIngestUrl(options.dsn)) {
630
+ if (!initFailureLogged) {
631
+ initFailureLogged = true;
632
+ logInitFailure(
633
+ options,
634
+ "dsn must be the full ingest URL, got a bare key (or a malformed URL); expected https://<host>/api/errors/v1/ingest/<key>. Error reporting is disabled.",
635
+ );
544
636
  }
545
637
  return;
546
638
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realuptime/errors",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Zero-dependency error tracking SDK for JavaScript and Node (RealUptime Errors).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,9 +54,9 @@
54
54
  "test": "vitest run"
55
55
  },
56
56
  "devDependencies": {
57
- "@types/node": "^22.20.0",
58
- "jsdom": "30.0.1",
57
+ "@types/node": "^24.19.2",
58
+ "jsdom": "30.1.3",
59
59
  "typescript": "^6.0.3",
60
- "vitest": "^4.1.10"
60
+ "vitest": "^5.0.5"
61
61
  }
62
62
  }
package/scrub.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { WireBreadcrumb, WireDevice, WireEvent, WireRequest, WireUser } from "./types.ts";
2
2
 
3
3
  /**
4
- * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
4
+ * PII scrub-by-default, client-side (the internal-docs repo's errors-plan.md, "PII
5
5
  * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
6
6
  * value never transits realuptime's wire; the server runs the same rules
7
7
  * again as a second net (packages/db/error-scrub.ts), and the shared
@@ -10,15 +10,32 @@ import type { WireBreadcrumb, WireDevice, WireEvent, WireRequest, WireUser } fro
10
10
  *
11
11
  * The rules, in the order they run over every scrubbed string:
12
12
  *
13
+ * 0. URL userinfo credentials (`scheme://user:pass@host`, REA-474): the
14
+ * userinfo is replaced whole; the host stays readable. Runs first so a
15
+ * password that happens to look hex- or base64-shaped is not partially
16
+ * eaten by a later rule before the URL rule can see the whole userinfo.
13
17
  * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
14
- * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
15
- * followed by 8+ token characters.
18
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_/
19
+ * whsec_ (REA-474) followed by 8+ token characters.
20
+ * 2b. AWS access-key ids (REA-474): AKIA/ASIA followed by exactly 16
21
+ * uppercase-alnum characters.
16
22
  * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
17
23
  * separators, passing Luhn. A digit run that fails Luhn is an order
18
24
  * id, not a card, and stays.
19
25
  * 4. Long hex runs (32+).
20
26
  * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
21
27
  *
28
+ * BEFORE any of the above, a KEY-NAME denylist pass (REA-474) runs over
29
+ * every flat string map (tags, custom context, device, breadcrumb data,
30
+ * stack-frame locals) and over request headers: a key whose name -- read as
31
+ * whole snake_case/camelCase segments, so "tokenizer" and "authentic" do
32
+ * NOT match "token" and "auth" -- is password, passwd, secret, token,
33
+ * api_key/apikey, auth, authorization, credential(s), private_key,
34
+ * access_key, or session, has its value replaced whole regardless of shape.
35
+ * For headers this is folded into the existing REMOVED_HEADERS check (same
36
+ * allowFields opt-back-in); for the other maps it is unconditional, the
37
+ * same as the maps' key set itself.
38
+ *
22
39
  * Scrubbed strings: the message, every request header value, the request
23
40
  * path and route, every breadcrumb message and breadcrumb data value
24
41
  * (Phase 3), any source-context lines a frame carries (a hardcoded secret
@@ -55,12 +72,60 @@ const REMOVED_HEADERS = new Set(["authorization", "proxy-authorization", "cookie
55
72
  * `user.id` is deliberately absent: see the module doc. */
56
73
  export const REMOVED_USER_FIELDS = ["user.email", "user.username"] as const;
57
74
 
75
+ const URL_CREDS_RE = /\b([a-zA-Z][a-zA-Z0-9+.-]{0,31}):\/\/[^\s/@:]+:[^\s/@]+@/g;
58
76
  const JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
59
- const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/g;
77
+ const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live|whsec)_[A-Za-z0-9_-]{8,}/g;
78
+ const AWS_KEY_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
60
79
  const CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
61
80
  const HEX_RE = /\b[0-9a-fA-F]{32,}\b/g;
62
81
  const BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/g;
63
82
 
83
+ /** Key-name denylist (REA-474): normalized (lowercased, separator-joined)
84
+ * segments that mark a field as sensitive by name alone, regardless of what
85
+ * its value looks like. Kept as single normalized words/compounds so a
86
+ * whole-segment match never fires on a substring -- "tokenizer" is one
87
+ * segment, "tokenizer", not "token" plus a suffix. */
88
+ const KEY_DENYLIST = new Set([
89
+ "password",
90
+ "passwd",
91
+ "secret",
92
+ "token",
93
+ "apikey",
94
+ "auth",
95
+ "authorization",
96
+ "credential",
97
+ "credentials",
98
+ "privatekey",
99
+ "accesskey",
100
+ "session",
101
+ ]);
102
+
103
+ /** Splits a key into lowercase segments on camelCase boundaries and any
104
+ * non-alphanumeric separator (snake_case, kebab-case, dotted). */
105
+ function keySegments(key: string): string[] {
106
+ return key
107
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
108
+ .split(/[^a-zA-Z0-9]+/)
109
+ .filter(Boolean)
110
+ .map((s) => s.toLowerCase());
111
+ }
112
+
113
+ /** True when some contiguous run of a key's segments, joined with no
114
+ * separator, equals a denylisted word exactly -- catches "api_key",
115
+ * "apiKey" and "db_password" alike, without matching "tokenizer" or
116
+ * "authentic". */
117
+ export function isKeyDenylisted(key: string): boolean {
118
+ const segments = keySegments(key);
119
+ for (let i = 0; i < segments.length; i++) {
120
+ let joined = "";
121
+ for (let j = i; j < segments.length; j++) {
122
+ joined += segments[j];
123
+ if (KEY_DENYLIST.has(joined)) return true;
124
+ }
125
+ }
126
+ return false;
127
+ }
128
+
64
129
  function luhnValid(digits: string): boolean {
65
130
  let sum = 0;
66
131
  let double = false;
@@ -79,8 +144,10 @@ function luhnValid(digits: string): boolean {
79
144
  /** Applies the five pattern rules to one string. Pure; exported so the
80
145
  * vector tests can drive it directly. */
81
146
  export function scrubString(value: string): string {
82
- let out = value.replace(JWT_RE, SCRUBBED);
147
+ let out = value.replace(URL_CREDS_RE, (_match, scheme: string) => `${scheme}://${SCRUBBED}@`);
148
+ out = out.replace(JWT_RE, SCRUBBED);
83
149
  out = out.replace(PREFIXED_KEY_RE, SCRUBBED);
150
+ out = out.replace(AWS_KEY_RE, SCRUBBED);
84
151
  out = out.replace(CARD_RE, (run) => {
85
152
  const digits = run.replace(/[ -]/g, "");
86
153
  return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? SCRUBBED : run;
@@ -98,7 +165,7 @@ function scrubRequest(request: WireRequest, allowFields: ReadonlySet<string>): W
98
165
  const headers: Record<string, string> = {};
99
166
  for (const [name, value] of Object.entries(out.headers)) {
100
167
  const lower = name.toLowerCase();
101
- if (REMOVED_HEADERS.has(lower) && !allowFields.has(lower)) {
168
+ if ((REMOVED_HEADERS.has(lower) || isKeyDenylisted(lower)) && !allowFields.has(lower)) {
102
169
  headers[name] = SCRUBBED;
103
170
  } else {
104
171
  headers[name] = scrubString(value);
@@ -113,7 +180,9 @@ function scrubBreadcrumb(crumb: WireBreadcrumb): WireBreadcrumb {
113
180
  let data: Record<string, string> | null = null;
114
181
  if (crumb.data) {
115
182
  data = {};
116
- for (const [name, value] of Object.entries(crumb.data)) data[name] = scrubString(value);
183
+ for (const [name, value] of Object.entries(crumb.data)) {
184
+ data[name] = isKeyDenylisted(name) ? SCRUBBED : scrubString(value);
185
+ }
117
186
  }
118
187
  return { ...crumb, message: scrubString(crumb.message), data };
119
188
  }
@@ -138,7 +207,11 @@ function scrubUser(user: WireUser, allowFields: ReadonlySet<string>): WireUser {
138
207
  function scrubStringMap(map: Record<string, string>): Record<string, string> {
139
208
  const out: Record<string, string> = {};
140
209
  for (const [name, value] of Object.entries(map)) {
141
- out[name] = typeof value === "string" ? scrubString(value) : value;
210
+ if (typeof value !== "string") {
211
+ out[name] = value;
212
+ } else {
213
+ out[name] = isKeyDenylisted(name) ? SCRUBBED : scrubString(value);
214
+ }
142
215
  }
143
216
  return out;
144
217
  }
package/sourcemaps.ts CHANGED
@@ -13,6 +13,12 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
13
13
  * secret a CI step needs is the DSN the app already has; this module derives
14
14
  * the upload URL from that DSN (or from a bare key plus an origin).
15
15
  *
16
+ * Only the main host serves that route. A DSN issued since the hostname split
17
+ * names the intake host, which takes error events and nothing as heavy as an
18
+ * upload (up to 10 files of 2 MB each), so a DSN on the intake host uploads to
19
+ * the main host. The intake answers an upload that still arrives there with a
20
+ * redirect to the main host (apps/ingest), for clients published before this.
21
+ *
16
22
  * Honesty rules, same as the rest of the SDK:
17
23
  * - Local refusals happen BEFORE any byte is sent (a file over the per-file
18
24
  * cap, more files than a release may hold, no `.map` files found), and
@@ -38,6 +44,11 @@ export const MAX_ARTIFACTS_PER_UPLOAD = 10;
38
44
 
39
45
  export const DEFAULT_API_ORIGIN = "https://realuptime.io";
40
46
 
47
+ /** The intake host every DSN issued since the hostname split names
48
+ * (`ingestBaseUrl` in packages/db/errors-dsn.ts). Source maps are not
49
+ * uploaded there; see the module comment. */
50
+ export const INGEST_HOSTNAME = "ingest.realuptime.io";
51
+
41
52
  const INGEST_KEY_SHAPE = /^rue_[0-9a-f]{32,128}$/;
42
53
 
43
54
  export class SourcemapUploadError extends Error {
@@ -53,7 +64,7 @@ export class SourcemapUploadError extends Error {
53
64
  }
54
65
 
55
66
  export interface UploadTarget {
56
- /** The project DSN, e.g. `https://realuptime.io/api/errors/v1/ingest/rue_...`. */
67
+ /** The project DSN, e.g. `https://ingest.realuptime.io/api/errors/v1/ingest/rue_...`. */
57
68
  dsn?: string;
58
69
  /** A bare ingest key, used with `origin` when no DSN is at hand. */
59
70
  ingestKey?: string;
@@ -62,8 +73,12 @@ export interface UploadTarget {
62
73
  }
63
74
 
64
75
  /**
65
- * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
66
- * A DSN wins over a bare key when both are given; either is enough.
76
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`,
77
+ * except that a DSN on the intake host (`ingest.realuptime.io`) uploads to the
78
+ * main host, `https://realuptime.io`, because only the main host stores maps.
79
+ * Any other DSN host (a pre-split `realuptime.io` DSN, a self-hosted or local
80
+ * one) keeps its own origin. A DSN wins over a bare key when both are given;
81
+ * either is enough.
67
82
  */
68
83
  export function resolveSourcemapUploadUrl(target: UploadTarget): string {
69
84
  if (target.dsn) {
@@ -77,7 +92,8 @@ export function resolveSourcemapUploadUrl(target: UploadTarget): string {
77
92
  if (!match) {
78
93
  throw new SourcemapUploadError("The DSN does not look like a realuptime errors DSN (expected .../api/errors/v1/ingest/rue_...).");
79
94
  }
80
- return `${url.origin}${match[1]}/sourcemaps/${match[2]}`;
95
+ const origin = url.hostname === INGEST_HOSTNAME ? DEFAULT_API_ORIGIN : url.origin;
96
+ return `${origin}${match[1]}/sourcemaps/${match[2]}`;
81
97
  }
82
98
  if (target.ingestKey) {
83
99
  if (!INGEST_KEY_SHAPE.test(target.ingestKey)) {
package/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Errors wire contract (docs/errors-plan.md, "The SDKs: two
2
+ * The Errors wire contract (the internal-docs repo's errors-plan.md, "The SDKs: two
3
3
  * languages, one contract").
4
4
  *
5
5
  * DUPLICATED, not imported. Same convention as apps/agent/types.ts and for
@@ -179,8 +179,18 @@ export interface WireEvent {
179
179
  * user's report to this exact occurrence. An integration that never
180
180
  * enables feedback keeps emitting the id-less payload it always did. */
181
181
  eventId?: string;
182
+ /** REA-569: severity, Sentry's five-value convention. Per-capture only
183
+ * (CaptureContext.level, not a sticky init()/scope option): unlike
184
+ * environment or release, a severity is a fact about THIS occurrence, not
185
+ * about the process. Absent resolves to "error" server-side. */
186
+ level?: ErrorLevel;
182
187
  }
183
188
 
189
+ /** REA-569: the closed severity set, Sentry's own five values so a
190
+ * migrating customer's rule filters translate directly. */
191
+ export type ErrorLevel = "fatal" | "error" | "warning" | "info" | "debug";
192
+ export const ERROR_LEVELS: readonly ErrorLevel[] = ["fatal", "error", "warning", "info", "debug"];
193
+
184
194
  export interface WireBatch {
185
195
  /** e.g. "realuptime-errors-js/0.1.0" */
186
196
  sdk: string;
@@ -282,7 +292,7 @@ export interface WireFeedback {
282
292
  * filled it. Scrubbed by the same pattern rules. */
283
293
  name?: string;
284
294
  email?: string;
285
- /** e.g. "realuptime-errors-js/0.4.0" */
295
+ /** e.g. "realuptime-errors-js/0.5.0" */
286
296
  sdk: string;
287
297
  }
288
298