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