@realuptime/errors 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/buffer.ts +52 -0
- package/client.ts +181 -0
- package/custom-scrub.ts +271 -0
- package/dist/buffer.d.ts +25 -0
- package/dist/buffer.js +42 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +151 -0
- package/dist/custom-scrub.d.ts +48 -0
- package/dist/custom-scrub.js +262 -0
- package/dist/feedback.d.ts +173 -0
- package/dist/feedback.js +465 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +516 -0
- package/dist/scrub.d.ts +60 -0
- package/dist/scrub.js +172 -0
- package/dist/sourcemaps.d.ts +142 -0
- package/dist/sourcemaps.js +262 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.js +79 -0
- package/dist/vite.d.ts +76 -0
- package/dist/vite.js +75 -0
- package/dist/webpack.d.ts +38 -0
- package/dist/webpack.js +15 -0
- package/feedback.ts +617 -0
- package/index.ts +617 -0
- package/package.json +62 -0
- package/scrub.ts +181 -0
- package/sourcemaps.ts +318 -0
- package/types.ts +293 -0
- package/vite.ts +155 -0
- package/webpack.ts +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { type FeedbackOptions, type ReportDialogHandle, type ReportDialogOptions } from "./feedback.ts";
|
|
2
|
+
export type { FeedbackOptions, ReportDialogField, ReportDialogHandle, ReportDialogOptions, ReportDialogTheme } from "./feedback.ts";
|
|
3
|
+
import { type CustomScrubRule } from "./custom-scrub.ts";
|
|
4
|
+
import { type WireFrame, type WireRequest, type WireUser } from "./types.ts";
|
|
5
|
+
/**
|
|
6
|
+
* @realuptime/errors: minimal JS/Node error tracking SDK, Phase 1
|
|
7
|
+
* (docs/errors-plan.md). Captures unhandled exceptions and rejections plus
|
|
8
|
+
* manual `captureException`/`captureMessage`, scrubs PII client-side by
|
|
9
|
+
* default (scrub.ts), and posts to the project's DSN-style ingest endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Contract with the host app, non-negotiable: THIS SDK NEVER THROWS. Every
|
|
12
|
+
* public function catches everything; a broken SDK logs once and goes
|
|
13
|
+
* quiet. An error tracker that crashes the app it watches is worse than
|
|
14
|
+
* none.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SDK_NAME = "realuptime-errors-js";
|
|
17
|
+
export declare const SDK_VERSION = "0.4.0";
|
|
18
|
+
/** How long the one-time rules fetch may take before the SDK stops waiting
|
|
19
|
+
* for it (it was never blocking capture anyway). */
|
|
20
|
+
export declare const SCRUB_RULES_TIMEOUT_MS = 5000;
|
|
21
|
+
export interface ErrorsInit {
|
|
22
|
+
/** The project's ingest URL, e.g.
|
|
23
|
+
* `https://realuptime.io/api/errors/v1/ingest/rue_...`. Shown once at
|
|
24
|
+
* project creation; safe to embed client-side (it can only submit events
|
|
25
|
+
* to its one project). */
|
|
26
|
+
dsn: string;
|
|
27
|
+
release?: string;
|
|
28
|
+
environment?: string;
|
|
29
|
+
/** Per-field scrub opt-back-in, lowercased header/field names (v2 adds
|
|
30
|
+
* the dotted identity names `user.email` and `user.username`). Never a
|
|
31
|
+
* global off switch, by design. */
|
|
32
|
+
allowFields?: string[];
|
|
33
|
+
/** REA-264: ADDITIONAL scrub rules for this process, applied after the
|
|
34
|
+
* default scrub on top of whatever the project's settings define. Each is
|
|
35
|
+
* `{ kind: "field_name" | "key_glob" | "value_regex", pattern }`; an
|
|
36
|
+
* invalid one is skipped. There is no option that relaxes a default. */
|
|
37
|
+
scrubRules?: CustomScrubRule[];
|
|
38
|
+
/** REA-264: fetch the project's additional scrub rules (set in realuptime
|
|
39
|
+
* Errors settings) once at init and apply them client-side too. Default
|
|
40
|
+
* true. Fails open to the defaults: a slow or unreachable fetch never
|
|
41
|
+
* delays or drops an event, and the server applies the same rules at
|
|
42
|
+
* ingest regardless. Set false to skip the request (e.g. in tests). */
|
|
43
|
+
fetchScrubRules?: boolean;
|
|
44
|
+
/** v2: report the runtime and platform this process runs on
|
|
45
|
+
* (`device.runtime` / `runtimeVersion` / `platform` / `arch`). Default
|
|
46
|
+
* true: these are facts about OUR customer's own server, not about their
|
|
47
|
+
* users, and "which Node version does this only happen on" is a question
|
|
48
|
+
* worth one line of payload. Set false to send nothing.
|
|
49
|
+
*
|
|
50
|
+
* Never includes hostname, local IP or container id. See WireDevice. */
|
|
51
|
+
sendDeviceInfo?: boolean;
|
|
52
|
+
/** Install process/window handlers for uncaught errors. Default true. */
|
|
53
|
+
captureUnhandled?: boolean;
|
|
54
|
+
/** REA-257: user feedback (the "what happened?" dialog). OFF unless this
|
|
55
|
+
* object is present; its presence is the opt-in. When set, every event
|
|
56
|
+
* carries a client-minted `eventId` so a report can attach to it, and
|
|
57
|
+
* `showReportDialog()` works. `autoShow: true` additionally opens the
|
|
58
|
+
* dialog after an unhandled browser exception. The project's feedback
|
|
59
|
+
* switch in realuptime Errors settings must ALSO be on, or the endpoint
|
|
60
|
+
* refuses with `reason: "feedback_disabled"`. See feedback.ts. */
|
|
61
|
+
feedback?: FeedbackOptions;
|
|
62
|
+
/** Test seams. */
|
|
63
|
+
fetchImpl?: typeof fetch;
|
|
64
|
+
now?: () => number;
|
|
65
|
+
log?: (message: string) => void;
|
|
66
|
+
}
|
|
67
|
+
/** Parses a V8/JavaScriptCore/SpiderMonkey stack string into wire frames,
|
|
68
|
+
* innermost first. Best-effort: an unparseable line is skipped, never
|
|
69
|
+
* fatal. */
|
|
70
|
+
export declare function parseStack(stack: string | undefined): WireFrame[] | null;
|
|
71
|
+
export interface CaptureContext {
|
|
72
|
+
request?: WireRequest;
|
|
73
|
+
fingerprint?: string[];
|
|
74
|
+
release?: string;
|
|
75
|
+
environment?: string;
|
|
76
|
+
/** v2: identity for THIS event only, merged over the sticky scope. */
|
|
77
|
+
user?: WireUser | null;
|
|
78
|
+
/** v2: tags for THIS event only, merged over the sticky scope. */
|
|
79
|
+
tags?: Record<string, string>;
|
|
80
|
+
/** v2: custom context for THIS event only, merged over the sticky scope. */
|
|
81
|
+
context?: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* v2 (REA-182): sets the sticky identity applied to every subsequent event.
|
|
85
|
+
* `null` clears it (sign-out).
|
|
86
|
+
*
|
|
87
|
+
* Only `id`, `email` and `username` are carried; anything else on the object
|
|
88
|
+
* is ignored rather than forwarded, so a caller who hands us their whole
|
|
89
|
+
* user record does not accidentally ship its address book. `email` and
|
|
90
|
+
* `username` are `[scrubbed]` before serialization unless the matching
|
|
91
|
+
* `allowFields` entry is set. Never throws.
|
|
92
|
+
*/
|
|
93
|
+
export declare function setUser(user: WireUser | null): void;
|
|
94
|
+
/** v2: sets one sticky tag. A null/undefined value removes it. Tags past
|
|
95
|
+
* MAX_TAGS_PER_EVENT are ignored rather than evicting an existing one --
|
|
96
|
+
* silently replacing what the integrator set earlier would be the worse
|
|
97
|
+
* surprise. Never throws. */
|
|
98
|
+
export declare function setTag(key: string, value: string | null | undefined): void;
|
|
99
|
+
/** v2: merges several sticky tags at once. Never throws. */
|
|
100
|
+
export declare function setTags(tags: Record<string, string>): void;
|
|
101
|
+
/**
|
|
102
|
+
* v2: sets one sticky custom-context entry, or merges a whole map.
|
|
103
|
+
*
|
|
104
|
+
* setContext("tenant", "acme");
|
|
105
|
+
* setContext({ tenant: "acme", shard: "eu-1" });
|
|
106
|
+
*
|
|
107
|
+
* A null/undefined value removes the named entry; passing null as the only
|
|
108
|
+
* argument clears the whole context. Never throws.
|
|
109
|
+
*/
|
|
110
|
+
export declare function setContext(keyOrMap: string | Record<string, string> | null, value?: string | null): void;
|
|
111
|
+
export interface BreadcrumbInput {
|
|
112
|
+
message: string;
|
|
113
|
+
category?: string;
|
|
114
|
+
/** Short string values only; scrubbed like every other captured string. */
|
|
115
|
+
data?: Record<string, string>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Records one breadcrumb onto the bounded trail (Phase 3). The trail rides
|
|
119
|
+
* the NEXT captured event, newest last, at most MAX_BREADCRUMBS_PER_EVENT;
|
|
120
|
+
* older entries are evicted and the eviction COUNT rides the event too
|
|
121
|
+
* (`breadcrumbsDropped`), so a truncated trail is never presented as the
|
|
122
|
+
* whole story. Never throws.
|
|
123
|
+
*/
|
|
124
|
+
export declare function addBreadcrumb(input: BreadcrumbInput): void;
|
|
125
|
+
/** Reports a thrown value. Non-Error values are stringified honestly rather
|
|
126
|
+
* than wrapped in a fake stack.
|
|
127
|
+
*
|
|
128
|
+
* Returns the event's client id when feedback is enabled (REA-257), so the
|
|
129
|
+
* caller can hand it to `showReportDialog({ eventId })`; null otherwise.
|
|
130
|
+
* Additive: every pre-existing caller ignored the `void` this returned. */
|
|
131
|
+
export declare function captureException(error: unknown, context?: CaptureContext): string | null;
|
|
132
|
+
export declare function captureMessage(message: string, context?: CaptureContext): string | null;
|
|
133
|
+
/** REA-257: the client id of the most recently captured event, or null
|
|
134
|
+
* when none was captured yet or feedback is not enabled. */
|
|
135
|
+
export declare function lastEventId(): string | null;
|
|
136
|
+
/**
|
|
137
|
+
* REA-257: opens the user feedback dialog for one event (default: the last
|
|
138
|
+
* captured one). Browser only; returns null and logs once anywhere else,
|
|
139
|
+
* when feedback was not enabled in `init`, when there is no event to attach
|
|
140
|
+
* to, or when a dialog is already open. Never throws.
|
|
141
|
+
*
|
|
142
|
+
* const eventId = captureException(err);
|
|
143
|
+
* showReportDialog({ eventId });
|
|
144
|
+
*/
|
|
145
|
+
export declare function showReportDialog(options?: ReportDialogOptions): ReportDialogHandle | null;
|
|
146
|
+
/** Initializes the SDK. Safe to call more than once (the last call wins);
|
|
147
|
+
* safe to call with a bad DSN (logs once, stays inert). */
|
|
148
|
+
export declare function init(options: ErrorsInit): void;
|
|
149
|
+
/** Flushes buffered events; resolves when the attempt finishes. Never
|
|
150
|
+
* rejects. */
|
|
151
|
+
export declare function flush(): Promise<void>;
|
|
152
|
+
/** Removes handlers and drops state. Mostly a test seam; a long-lived app
|
|
153
|
+
* has no reason to call it. */
|
|
154
|
+
export declare function close(): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { Transport } from "./client.js";
|
|
2
|
+
import { closeReportDialog, createFeedbackTransport, feedbackEndpointFor, isEventId, isReportDialogOpen, mintEventId, openReportDialog, } from "./feedback.js";
|
|
3
|
+
import { scrubEvent } from "./scrub.js";
|
|
4
|
+
import { applyCustomScrubRules, compileCustomScrubRules, NO_CUSTOM_SCRUB_RULES, scrubRulesUrlFor, } from "./custom-scrub.js";
|
|
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
|
+
/**
|
|
7
|
+
* @realuptime/errors: minimal JS/Node error tracking SDK, Phase 1
|
|
8
|
+
* (docs/errors-plan.md). Captures unhandled exceptions and rejections plus
|
|
9
|
+
* manual `captureException`/`captureMessage`, scrubs PII client-side by
|
|
10
|
+
* default (scrub.ts), and posts to the project's DSN-style ingest endpoint.
|
|
11
|
+
*
|
|
12
|
+
* Contract with the host app, non-negotiable: THIS SDK NEVER THROWS. Every
|
|
13
|
+
* public function catches everything; a broken SDK logs once and goes
|
|
14
|
+
* quiet. An error tracker that crashes the app it watches is worse than
|
|
15
|
+
* none.
|
|
16
|
+
*/
|
|
17
|
+
export const SDK_NAME = "realuptime-errors-js";
|
|
18
|
+
export const SDK_VERSION = "0.4.0";
|
|
19
|
+
/** How long the one-time rules fetch may take before the SDK stops waiting
|
|
20
|
+
* for it (it was never blocking capture anyway). */
|
|
21
|
+
export const SCRUB_RULES_TIMEOUT_MS = 5_000;
|
|
22
|
+
let state = null;
|
|
23
|
+
let initFailureLogged = false;
|
|
24
|
+
function safeLog(message) {
|
|
25
|
+
try {
|
|
26
|
+
(state?.options.log ?? console.error)(`[realuptime-errors] ${message}`);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Even logging must not throw.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function clip(value, max) {
|
|
33
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
34
|
+
}
|
|
35
|
+
/** Parses a V8/JavaScriptCore/SpiderMonkey stack string into wire frames,
|
|
36
|
+
* innermost first. Best-effort: an unparseable line is skipped, never
|
|
37
|
+
* fatal. */
|
|
38
|
+
export function parseStack(stack) {
|
|
39
|
+
if (!stack)
|
|
40
|
+
return null;
|
|
41
|
+
const frames = [];
|
|
42
|
+
for (const line of stack.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
// V8: "at fn (file:line:col)" or "at file:line:col"
|
|
45
|
+
let match = /^at\s+(?:(.*?)\s+\()?(.*?):(\d+):(\d+)\)?$/.exec(trimmed);
|
|
46
|
+
if (!match) {
|
|
47
|
+
// Firefox/Safari: "fn@file:line:col"
|
|
48
|
+
match = /^(.*?)@(.*?):(\d+):(\d+)$/.exec(trimmed);
|
|
49
|
+
}
|
|
50
|
+
if (!match)
|
|
51
|
+
continue;
|
|
52
|
+
const [, fn, file, lineNo, columnNo] = match;
|
|
53
|
+
if (!file)
|
|
54
|
+
continue;
|
|
55
|
+
frames.push({
|
|
56
|
+
file: clip(file, MAX_STRING_LENGTH),
|
|
57
|
+
function: fn ? clip(fn, MAX_STRING_LENGTH) : null,
|
|
58
|
+
line: Number.parseInt(lineNo, 10),
|
|
59
|
+
// The column the runtime reported, kept for source-map symbolication
|
|
60
|
+
// (Phase 3). Absence server-side is an honest "no_column" verdict.
|
|
61
|
+
column: Number.parseInt(columnNo, 10),
|
|
62
|
+
inApp: !file.includes("node_modules") && !file.startsWith("node:"),
|
|
63
|
+
});
|
|
64
|
+
if (frames.length >= MAX_FRAMES_PER_EVENT)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return frames.length > 0 ? frames : null;
|
|
68
|
+
}
|
|
69
|
+
/** Bounds one caller-supplied string map: keys and values clipped, entries
|
|
70
|
+
* past `max` dropped. Applied at WRITE time (setTag/setContext, and the
|
|
71
|
+
* per-capture overrides) rather than at send time so the sticky scope of a
|
|
72
|
+
* long-lived process is bounded by construction. */
|
|
73
|
+
function boundedMap(input, max, valueMax = MAX_STRING_LENGTH) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [name, value] of Object.entries(input)) {
|
|
76
|
+
if (Object.keys(out).length >= max)
|
|
77
|
+
break;
|
|
78
|
+
if (typeof value !== "string" || typeof name !== "string" || !name)
|
|
79
|
+
continue;
|
|
80
|
+
out[clip(name, MAX_CONTEXT_KEY_LENGTH)] = clip(value, valueMax);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Reads the runtime facts out of this process. Node only for now: a
|
|
85
|
+
* browser's navigator.userAgent is a fingerprinting surface and a lie in
|
|
86
|
+
* equal measure, so the browser build reports nothing rather than
|
|
87
|
+
* something. Never throws; unknown fields are simply absent. */
|
|
88
|
+
function detectDevice() {
|
|
89
|
+
try {
|
|
90
|
+
const proc = globalThis.process;
|
|
91
|
+
if (!proc?.versions?.node)
|
|
92
|
+
return null;
|
|
93
|
+
const device = { runtime: "node", runtimeVersion: clip(proc.versions.node, MAX_STRING_LENGTH) };
|
|
94
|
+
if (typeof proc.platform === "string")
|
|
95
|
+
device.platform = clip(proc.platform, MAX_STRING_LENGTH);
|
|
96
|
+
if (typeof proc.arch === "string")
|
|
97
|
+
device.arch = clip(proc.arch, MAX_STRING_LENGTH);
|
|
98
|
+
return device;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* v2 (REA-182): sets the sticky identity applied to every subsequent event.
|
|
106
|
+
* `null` clears it (sign-out).
|
|
107
|
+
*
|
|
108
|
+
* Only `id`, `email` and `username` are carried; anything else on the object
|
|
109
|
+
* is ignored rather than forwarded, so a caller who hands us their whole
|
|
110
|
+
* user record does not accidentally ship its address book. `email` and
|
|
111
|
+
* `username` are `[scrubbed]` before serialization unless the matching
|
|
112
|
+
* `allowFields` entry is set. Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function setUser(user) {
|
|
115
|
+
try {
|
|
116
|
+
if (!state)
|
|
117
|
+
return;
|
|
118
|
+
if (user === null || typeof user !== "object") {
|
|
119
|
+
state.user = null;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const next = {};
|
|
123
|
+
if (typeof user.id === "string")
|
|
124
|
+
next.id = clip(user.id, MAX_STRING_LENGTH);
|
|
125
|
+
if (typeof user.email === "string")
|
|
126
|
+
next.email = clip(user.email, MAX_STRING_LENGTH);
|
|
127
|
+
if (typeof user.username === "string")
|
|
128
|
+
next.username = clip(user.username, MAX_STRING_LENGTH);
|
|
129
|
+
state.user = Object.keys(next).length > 0 ? next : null;
|
|
130
|
+
}
|
|
131
|
+
catch (failure) {
|
|
132
|
+
safeLog(`setUser failed: ${String(failure)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** v2: sets one sticky tag. A null/undefined value removes it. Tags past
|
|
136
|
+
* MAX_TAGS_PER_EVENT are ignored rather than evicting an existing one --
|
|
137
|
+
* silently replacing what the integrator set earlier would be the worse
|
|
138
|
+
* surprise. Never throws. */
|
|
139
|
+
export function setTag(key, value) {
|
|
140
|
+
try {
|
|
141
|
+
if (!state || typeof key !== "string" || !key)
|
|
142
|
+
return;
|
|
143
|
+
const name = clip(key, MAX_CONTEXT_KEY_LENGTH);
|
|
144
|
+
if (value === null || value === undefined) {
|
|
145
|
+
delete state.tags[name];
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (typeof value !== "string")
|
|
149
|
+
return;
|
|
150
|
+
if (!(name in state.tags) && Object.keys(state.tags).length >= MAX_TAGS_PER_EVENT)
|
|
151
|
+
return;
|
|
152
|
+
state.tags[name] = clip(value, MAX_STRING_LENGTH);
|
|
153
|
+
}
|
|
154
|
+
catch (failure) {
|
|
155
|
+
safeLog(`setTag failed: ${String(failure)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** v2: merges several sticky tags at once. Never throws. */
|
|
159
|
+
export function setTags(tags) {
|
|
160
|
+
try {
|
|
161
|
+
if (!state || typeof tags !== "object" || tags === null)
|
|
162
|
+
return;
|
|
163
|
+
for (const [name, value] of Object.entries(tags))
|
|
164
|
+
setTag(name, value);
|
|
165
|
+
}
|
|
166
|
+
catch (failure) {
|
|
167
|
+
safeLog(`setTags failed: ${String(failure)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* v2: sets one sticky custom-context entry, or merges a whole map.
|
|
172
|
+
*
|
|
173
|
+
* setContext("tenant", "acme");
|
|
174
|
+
* setContext({ tenant: "acme", shard: "eu-1" });
|
|
175
|
+
*
|
|
176
|
+
* A null/undefined value removes the named entry; passing null as the only
|
|
177
|
+
* argument clears the whole context. Never throws.
|
|
178
|
+
*/
|
|
179
|
+
export function setContext(keyOrMap, value) {
|
|
180
|
+
try {
|
|
181
|
+
if (!state)
|
|
182
|
+
return;
|
|
183
|
+
if (keyOrMap === null) {
|
|
184
|
+
state.context = {};
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (typeof keyOrMap === "object") {
|
|
188
|
+
const bounded = boundedMap(keyOrMap, MAX_CONTEXT_ENTRIES);
|
|
189
|
+
for (const [name, entry] of Object.entries(bounded)) {
|
|
190
|
+
if (!(name in state.context) && Object.keys(state.context).length >= MAX_CONTEXT_ENTRIES)
|
|
191
|
+
break;
|
|
192
|
+
state.context[name] = entry;
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (typeof keyOrMap !== "string" || !keyOrMap)
|
|
197
|
+
return;
|
|
198
|
+
const name = clip(keyOrMap, MAX_CONTEXT_KEY_LENGTH);
|
|
199
|
+
if (value === null || value === undefined) {
|
|
200
|
+
delete state.context[name];
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (typeof value !== "string")
|
|
204
|
+
return;
|
|
205
|
+
if (!(name in state.context) && Object.keys(state.context).length >= MAX_CONTEXT_ENTRIES)
|
|
206
|
+
return;
|
|
207
|
+
state.context[name] = clip(value, MAX_STRING_LENGTH);
|
|
208
|
+
}
|
|
209
|
+
catch (failure) {
|
|
210
|
+
safeLog(`setContext failed: ${String(failure)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function buildEvent(message, exceptionType, stack, context) {
|
|
214
|
+
const options = state?.options;
|
|
215
|
+
// v2 scope: the sticky values, with this capture's overrides merged over
|
|
216
|
+
// the top. Emitted only when non-empty, so an integration that never
|
|
217
|
+
// touches the v2 API keeps producing byte-identical v1 payloads.
|
|
218
|
+
const tags = { ...(state?.tags ?? {}), ...boundedMap(context.tags ?? {}, MAX_TAGS_PER_EVENT) };
|
|
219
|
+
const custom = { ...(state?.context ?? {}), ...boundedMap(context.context ?? {}, MAX_CONTEXT_ENTRIES) };
|
|
220
|
+
const user = context.user === null
|
|
221
|
+
? null
|
|
222
|
+
: context.user
|
|
223
|
+
? { ...(state?.user ?? {}), ...context.user }
|
|
224
|
+
: (state?.user ?? null);
|
|
225
|
+
return {
|
|
226
|
+
occurredAt: new Date(state?.options.now?.() ?? Date.now()).toISOString(),
|
|
227
|
+
message: clip(message, MAX_MESSAGE_LENGTH),
|
|
228
|
+
exceptionType: exceptionType ? clip(exceptionType, MAX_STRING_LENGTH) : null,
|
|
229
|
+
release: context.release ?? options?.release ?? null,
|
|
230
|
+
environment: context.environment ?? options?.environment ?? null,
|
|
231
|
+
frames: parseStack(stack),
|
|
232
|
+
request: context.request ?? null,
|
|
233
|
+
fingerprint: context.fingerprint ?? null,
|
|
234
|
+
breadcrumbs: state && state.breadcrumbs.length > 0 ? [...state.breadcrumbs] : null,
|
|
235
|
+
breadcrumbsDropped: state?.breadcrumbsEvicted ?? 0,
|
|
236
|
+
...(user ? { user } : {}),
|
|
237
|
+
...(Object.keys(tags).length > 0 ? { tags } : {}),
|
|
238
|
+
...(Object.keys(custom).length > 0 ? { context: custom } : {}),
|
|
239
|
+
...(state?.device ? { device: state.device } : {}),
|
|
240
|
+
// REA-257: minted only when feedback is enabled, so an integration that
|
|
241
|
+
// never opted in keeps emitting the id-less payload it always did.
|
|
242
|
+
...(state?.feedback ? { eventId: mintEventId() } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Records one breadcrumb onto the bounded trail (Phase 3). The trail rides
|
|
247
|
+
* the NEXT captured event, newest last, at most MAX_BREADCRUMBS_PER_EVENT;
|
|
248
|
+
* older entries are evicted and the eviction COUNT rides the event too
|
|
249
|
+
* (`breadcrumbsDropped`), so a truncated trail is never presented as the
|
|
250
|
+
* whole story. Never throws.
|
|
251
|
+
*/
|
|
252
|
+
export function addBreadcrumb(input) {
|
|
253
|
+
try {
|
|
254
|
+
if (!state || typeof input?.message !== "string")
|
|
255
|
+
return;
|
|
256
|
+
let data = null;
|
|
257
|
+
if (input.data) {
|
|
258
|
+
data = {};
|
|
259
|
+
for (const [name, value] of Object.entries(input.data).slice(0, MAX_BREADCRUMB_DATA_ENTRIES)) {
|
|
260
|
+
if (typeof value === "string")
|
|
261
|
+
data[clip(name, MAX_STRING_LENGTH)] = clip(value, MAX_STRING_LENGTH);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
state.breadcrumbs.push({
|
|
265
|
+
timestamp: new Date(state.options.now?.() ?? Date.now()).toISOString(),
|
|
266
|
+
category: input.category ? clip(input.category, 100) : null,
|
|
267
|
+
message: clip(input.message, MAX_STRING_LENGTH),
|
|
268
|
+
data,
|
|
269
|
+
});
|
|
270
|
+
if (state.breadcrumbs.length > MAX_BREADCRUMBS_PER_EVENT) {
|
|
271
|
+
state.breadcrumbs.shift();
|
|
272
|
+
state.breadcrumbsEvicted += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch (failure) {
|
|
276
|
+
safeLog(`addBreadcrumb failed: ${String(failure)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function capture(event) {
|
|
280
|
+
if (!state)
|
|
281
|
+
return null;
|
|
282
|
+
// Defaults first, always; the customer's additional rules on the result.
|
|
283
|
+
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;
|
|
288
|
+
}
|
|
289
|
+
/** Fetches the project's additional scrub rules by ingest key and merges
|
|
290
|
+
* them over the inline ones. Best-effort, bounded, never throws, never
|
|
291
|
+
* blocks capture: an event sent before the response lands gets the
|
|
292
|
+
* defaults plus inline rules here and the project's rules at ingest. */
|
|
293
|
+
async function fetchScrubRules(forState) {
|
|
294
|
+
try {
|
|
295
|
+
const url = scrubRulesUrlFor(forState.options.dsn);
|
|
296
|
+
if (!url)
|
|
297
|
+
return;
|
|
298
|
+
const fetchImpl = forState.options.fetchImpl ?? fetch;
|
|
299
|
+
const response = await fetchImpl(url, { method: "GET", signal: AbortSignal.timeout(SCRUB_RULES_TIMEOUT_MS) });
|
|
300
|
+
if (!response.ok)
|
|
301
|
+
return;
|
|
302
|
+
const body = (await response.json());
|
|
303
|
+
if (!Array.isArray(body?.rules))
|
|
304
|
+
return;
|
|
305
|
+
// Init may have been called again while this was in flight; only the
|
|
306
|
+
// state this fetch was started for is updated.
|
|
307
|
+
if (state !== forState)
|
|
308
|
+
return;
|
|
309
|
+
forState.customScrub = compileCustomScrubRules([...(forState.options.scrubRules ?? []), ...body.rules]);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Fail open to the defaults. Silent by design: a transient fetch miss
|
|
313
|
+
// is not the integrator's problem, and the server still applies them.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Reports a thrown value. Non-Error values are stringified honestly rather
|
|
317
|
+
* than wrapped in a fake stack.
|
|
318
|
+
*
|
|
319
|
+
* Returns the event's client id when feedback is enabled (REA-257), so the
|
|
320
|
+
* caller can hand it to `showReportDialog({ eventId })`; null otherwise.
|
|
321
|
+
* Additive: every pre-existing caller ignored the `void` this returned. */
|
|
322
|
+
export function captureException(error, context = {}) {
|
|
323
|
+
try {
|
|
324
|
+
if (!state)
|
|
325
|
+
return null;
|
|
326
|
+
if (error instanceof Error) {
|
|
327
|
+
return capture(buildEvent(error.message || error.name, error.name || "Error", error.stack, context));
|
|
328
|
+
}
|
|
329
|
+
return capture(buildEvent(String(error), null, undefined, context));
|
|
330
|
+
}
|
|
331
|
+
catch (failure) {
|
|
332
|
+
safeLog(`captureException failed: ${String(failure)}`);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
export function captureMessage(message, context = {}) {
|
|
337
|
+
try {
|
|
338
|
+
if (!state)
|
|
339
|
+
return null;
|
|
340
|
+
return capture(buildEvent(message, null, undefined, context));
|
|
341
|
+
}
|
|
342
|
+
catch (failure) {
|
|
343
|
+
safeLog(`captureMessage failed: ${String(failure)}`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** REA-257: the client id of the most recently captured event, or null
|
|
348
|
+
* when none was captured yet or feedback is not enabled. */
|
|
349
|
+
export function lastEventId() {
|
|
350
|
+
return state?.lastEventId ?? null;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* REA-257: opens the user feedback dialog for one event (default: the last
|
|
354
|
+
* captured one). Browser only; returns null and logs once anywhere else,
|
|
355
|
+
* when feedback was not enabled in `init`, when there is no event to attach
|
|
356
|
+
* to, or when a dialog is already open. Never throws.
|
|
357
|
+
*
|
|
358
|
+
* const eventId = captureException(err);
|
|
359
|
+
* showReportDialog({ eventId });
|
|
360
|
+
*/
|
|
361
|
+
export function showReportDialog(options = {}) {
|
|
362
|
+
try {
|
|
363
|
+
if (!state)
|
|
364
|
+
return null;
|
|
365
|
+
if (!state.feedback) {
|
|
366
|
+
safeLog("showReportDialog called but feedback is not enabled; pass init({ feedback: {} }) to turn it on.");
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const doc = globalThis.document;
|
|
370
|
+
if (!doc?.body)
|
|
371
|
+
return null;
|
|
372
|
+
const eventId = options.eventId ?? state.lastEventId;
|
|
373
|
+
if (!isEventId(eventId)) {
|
|
374
|
+
safeLog("showReportDialog: no event to attach to (capture one first, or pass eventId).");
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
if (isReportDialogOpen())
|
|
378
|
+
return null;
|
|
379
|
+
const { autoShow: _autoShow, ...standing } = state.feedback.options;
|
|
380
|
+
const current = state;
|
|
381
|
+
return openReportDialog({ ...standing, ...options, eventId }, {
|
|
382
|
+
document: doc,
|
|
383
|
+
transport: state.feedback.transport,
|
|
384
|
+
sdk: `${SDK_NAME}/${SDK_VERSION}`,
|
|
385
|
+
beforeSend: () => current.transport.flush(),
|
|
386
|
+
log: (message) => safeLog(message),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (failure) {
|
|
390
|
+
safeLog(`showReportDialog failed: ${String(failure)}`);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function installUnhandledHandlers() {
|
|
395
|
+
// Node first: process-level handlers see everything, including code that
|
|
396
|
+
// runs before any window exists. A runtime that has BOTH a process object
|
|
397
|
+
// and a DOM (an Electron renderer, a jsdom harness) is a page, and the
|
|
398
|
+
// window handlers are the ones that see its errors (REA-257).
|
|
399
|
+
const proc = globalThis.process;
|
|
400
|
+
const hasDom = typeof globalThis.document?.createElement === "function";
|
|
401
|
+
if (!hasDom && proc && typeof proc.on === "function") {
|
|
402
|
+
const onException = (error) => captureException(error);
|
|
403
|
+
const onRejection = (reason) => captureException(reason);
|
|
404
|
+
proc.on("uncaughtExceptionMonitor", onException);
|
|
405
|
+
proc.on("unhandledRejection", onRejection);
|
|
406
|
+
return () => {
|
|
407
|
+
proc.removeListener("uncaughtExceptionMonitor", onException);
|
|
408
|
+
proc.removeListener("unhandledRejection", onRejection);
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const win = globalThis;
|
|
412
|
+
if (typeof win.addEventListener === "function") {
|
|
413
|
+
// REA-257: the opt-in auto-show. Only after an UNHANDLED browser
|
|
414
|
+
// exception, only when the integrator asked, and only when the event
|
|
415
|
+
// actually got an id (feedback enabled).
|
|
416
|
+
const maybeShow = (eventId) => {
|
|
417
|
+
if (eventId && state?.feedback?.options.autoShow)
|
|
418
|
+
showReportDialog({ eventId });
|
|
419
|
+
};
|
|
420
|
+
const onError = (e) => maybeShow(captureException(e.error ?? e.message));
|
|
421
|
+
const onRejection = (e) => maybeShow(captureException(e.reason));
|
|
422
|
+
win.addEventListener("error", onError);
|
|
423
|
+
win.addEventListener("unhandledrejection", onRejection);
|
|
424
|
+
return () => {
|
|
425
|
+
win.removeEventListener("error", onError);
|
|
426
|
+
win.removeEventListener("unhandledrejection", onRejection);
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
/** Initializes the SDK. Safe to call more than once (the last call wins);
|
|
432
|
+
* safe to call with a bad DSN (logs once, stays inert). */
|
|
433
|
+
export function init(options) {
|
|
434
|
+
try {
|
|
435
|
+
if (!options?.dsn || typeof options.dsn !== "string") {
|
|
436
|
+
if (!initFailureLogged) {
|
|
437
|
+
initFailureLogged = true;
|
|
438
|
+
safeLog("init called without a dsn; error reporting is disabled.");
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
close();
|
|
443
|
+
state = {
|
|
444
|
+
options,
|
|
445
|
+
transport: new Transport({
|
|
446
|
+
endpoint: options.dsn,
|
|
447
|
+
sdk: `${SDK_NAME}/${SDK_VERSION}`,
|
|
448
|
+
fetchImpl: options.fetchImpl,
|
|
449
|
+
now: options.now,
|
|
450
|
+
log: options.log,
|
|
451
|
+
}),
|
|
452
|
+
uninstall: null,
|
|
453
|
+
breadcrumbs: [],
|
|
454
|
+
breadcrumbsEvicted: 0,
|
|
455
|
+
user: null,
|
|
456
|
+
tags: {},
|
|
457
|
+
context: {},
|
|
458
|
+
device: options.sendDeviceInfo === false ? null : detectDevice(),
|
|
459
|
+
customScrub: options.scrubRules ? compileCustomScrubRules(options.scrubRules) : NO_CUSTOM_SCRUB_RULES,
|
|
460
|
+
feedback: null,
|
|
461
|
+
lastEventId: null,
|
|
462
|
+
};
|
|
463
|
+
if (options.feedback && typeof options.feedback === "object") {
|
|
464
|
+
const endpoint = feedbackEndpointFor(options.dsn);
|
|
465
|
+
if (endpoint) {
|
|
466
|
+
state.feedback = {
|
|
467
|
+
options: options.feedback,
|
|
468
|
+
transport: createFeedbackTransport({
|
|
469
|
+
endpoint,
|
|
470
|
+
sdk: `${SDK_NAME}/${SDK_VERSION}`,
|
|
471
|
+
fetchImpl: options.fetchImpl,
|
|
472
|
+
log: options.log,
|
|
473
|
+
}),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
safeLog("feedback enabled but the dsn is not an ingest URL; feedback is disabled.");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (options.captureUnhandled !== false) {
|
|
481
|
+
state.uninstall = installUnhandledHandlers();
|
|
482
|
+
}
|
|
483
|
+
if (options.fetchScrubRules !== false)
|
|
484
|
+
void fetchScrubRules(state);
|
|
485
|
+
}
|
|
486
|
+
catch (failure) {
|
|
487
|
+
safeLog(`init failed: ${String(failure)}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** Flushes buffered events; resolves when the attempt finishes. Never
|
|
491
|
+
* rejects. */
|
|
492
|
+
export async function flush() {
|
|
493
|
+
try {
|
|
494
|
+
await state?.transport.flush();
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Never throws.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** Removes handlers and drops state. Mostly a test seam; a long-lived app
|
|
501
|
+
* has no reason to call it. */
|
|
502
|
+
export function close() {
|
|
503
|
+
try {
|
|
504
|
+
state?.uninstall?.();
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Never throws.
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
closeReportDialog();
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Never throws.
|
|
514
|
+
}
|
|
515
|
+
state = null;
|
|
516
|
+
}
|