@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 +2 -2
- package/client.ts +11 -3
- package/dist/client.d.ts +2 -2
- package/dist/client.js +9 -3
- package/dist/index.d.ts +24 -4
- package/dist/index.js +85 -8
- package/dist/scrub.d.ts +25 -3
- package/dist/scrub.js +80 -9
- package/dist/sourcemaps.d.ts +17 -3
- package/dist/sourcemaps.js +18 -3
- package/dist/types.d.ts +11 -2
- package/dist/types.js +2 -1
- package/index.ts +100 -8
- package/package.json +4 -4
- package/scrub.ts +81 -8
- package/sourcemaps.ts +20 -4
- package/types.ts +12 -2
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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/dist/sourcemaps.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
package/dist/sourcemaps.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
58
|
-
"jsdom": "30.
|
|
57
|
+
"@types/node": "^24.19.2",
|
|
58
|
+
"jsdom": "30.1.3",
|
|
59
59
|
"typescript": "^6.0.3",
|
|
60
|
-
"vitest": "^
|
|
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
|
|
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(
|
|
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))
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
295
|
+
/** e.g. "realuptime-errors-js/0.5.0" */
|
|
286
296
|
sdk: string;
|
|
287
297
|
}
|
|
288
298
|
|