@cross-deck/web 0.10.0 → 1.0.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/dist/index.cjs CHANGED
@@ -99,7 +99,7 @@ function typeMapForStatus(status) {
99
99
 
100
100
  // src/http.ts
101
101
  var SDK_NAME = "@cross-deck/web";
102
- var SDK_VERSION = "0.10.0";
102
+ var SDK_VERSION = "1.0.0";
103
103
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
104
104
  var DEFAULT_TIMEOUT_MS = 15e3;
105
105
  var HttpClient = class {
@@ -923,7 +923,8 @@ var DEFAULT_AUTO_TRACK = {
923
923
  pageViews: true,
924
924
  deviceInfo: true,
925
925
  clicks: true,
926
- webVitals: true
926
+ webVitals: true,
927
+ errors: true
927
928
  };
928
929
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
929
930
  var EMPTY_ACQUISITION = {
@@ -1824,6 +1825,541 @@ function scrubPiiFromProperties(properties) {
1824
1825
  return out;
1825
1826
  }
1826
1827
 
1828
+ // src/breadcrumbs.ts
1829
+ var BreadcrumbBuffer = class {
1830
+ constructor(maxSize = 50) {
1831
+ this.maxSize = maxSize;
1832
+ this.items = [];
1833
+ }
1834
+ add(crumb) {
1835
+ this.items.push(crumb);
1836
+ if (this.items.length > this.maxSize) {
1837
+ this.items.shift();
1838
+ }
1839
+ }
1840
+ /** Defensive copy — caller can read freely without mutating buffer state. */
1841
+ snapshot() {
1842
+ return this.items.slice();
1843
+ }
1844
+ clear() {
1845
+ this.items = [];
1846
+ }
1847
+ get size() {
1848
+ return this.items.length;
1849
+ }
1850
+ };
1851
+
1852
+ // src/stack-parser.ts
1853
+ function parseStack(stack) {
1854
+ if (!stack || typeof stack !== "string") return [];
1855
+ const lines = stack.split("\n");
1856
+ const frames = [];
1857
+ for (const line of lines) {
1858
+ const trimmed = line.trim();
1859
+ if (!trimmed) continue;
1860
+ const frame = parseLine(trimmed);
1861
+ if (frame) frames.push(frame);
1862
+ }
1863
+ return frames;
1864
+ }
1865
+ function parseLine(line) {
1866
+ let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
1867
+ if (m) {
1868
+ return buildFrame({
1869
+ function: m[1],
1870
+ filename: m[2],
1871
+ lineno: parseInt(m[3], 10),
1872
+ colno: parseInt(m[4], 10),
1873
+ raw: line
1874
+ });
1875
+ }
1876
+ m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
1877
+ if (m) {
1878
+ return buildFrame({
1879
+ function: "?",
1880
+ filename: m[1],
1881
+ lineno: parseInt(m[2], 10),
1882
+ colno: parseInt(m[3], 10),
1883
+ raw: line
1884
+ });
1885
+ }
1886
+ m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
1887
+ if (m) {
1888
+ return buildFrame({
1889
+ function: m[1] || "?",
1890
+ filename: m[2],
1891
+ lineno: parseInt(m[3], 10),
1892
+ colno: parseInt(m[4], 10),
1893
+ raw: line
1894
+ });
1895
+ }
1896
+ if (/^\w*Error/.test(line) || !line.includes(":")) {
1897
+ return null;
1898
+ }
1899
+ return {
1900
+ function: "?",
1901
+ filename: "",
1902
+ lineno: 0,
1903
+ colno: 0,
1904
+ in_app: true,
1905
+ raw: line
1906
+ };
1907
+ }
1908
+ function buildFrame(input) {
1909
+ return {
1910
+ function: input.function || "?",
1911
+ filename: input.filename,
1912
+ lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
1913
+ colno: Number.isFinite(input.colno) ? input.colno : 0,
1914
+ in_app: isInAppFrame(input.filename),
1915
+ raw: input.raw
1916
+ };
1917
+ }
1918
+ function isInAppFrame(filename) {
1919
+ if (!filename) return true;
1920
+ if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
1921
+ if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
1922
+ if (/\bunpkg\.com\b/.test(filename)) return false;
1923
+ if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
1924
+ if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
1925
+ if (/\b@cross-deck\/web\b/.test(filename)) return false;
1926
+ if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1927
+ return true;
1928
+ }
1929
+ function fingerprintError(message, frames) {
1930
+ const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1931
+ const key = [
1932
+ (message || "").slice(0, 200),
1933
+ ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1934
+ ].join("|");
1935
+ return djb2Hex(key);
1936
+ }
1937
+ function djb2Hex(input) {
1938
+ let h = 5381;
1939
+ for (let i = 0; i < input.length; i++) {
1940
+ h = (h << 5) + h + input.charCodeAt(i) | 0;
1941
+ }
1942
+ return (h >>> 0).toString(16).padStart(8, "0");
1943
+ }
1944
+
1945
+ // src/error-capture.ts
1946
+ var DEFAULT_ERROR_CAPTURE = {
1947
+ enabled: true,
1948
+ onError: true,
1949
+ onUnhandledRejection: true,
1950
+ wrapFetch: true,
1951
+ wrapXhr: true,
1952
+ captureConsole: false,
1953
+ ignoreErrors: [
1954
+ // Classic browser noise. These aren't application bugs.
1955
+ "ResizeObserver loop limit exceeded",
1956
+ "ResizeObserver loop completed with undelivered notifications",
1957
+ "Non-Error promise rejection captured",
1958
+ // Cross-origin script errors that the browser strips — no info,
1959
+ // no way to act on them, just noise.
1960
+ "Script error.",
1961
+ "Script error"
1962
+ ],
1963
+ allowUrls: [],
1964
+ denyUrls: [
1965
+ // Common third-party extensions that pollute error streams.
1966
+ /^chrome-extension:\/\//,
1967
+ /^moz-extension:\/\//,
1968
+ /^safari-extension:\/\//,
1969
+ /^webkit-extension:\/\//,
1970
+ /^safari-web-extension:\/\//
1971
+ ],
1972
+ sampleRate: 1,
1973
+ maxPerFingerprintPerMinute: 5,
1974
+ maxPerSession: 100
1975
+ };
1976
+ var ErrorTracker = class {
1977
+ constructor(opts) {
1978
+ this.opts = opts;
1979
+ this.installed = false;
1980
+ this.cleanups = [];
1981
+ this._reporting = false;
1982
+ this.sessionCount = 0;
1983
+ this.fingerprintWindow = /* @__PURE__ */ new Map();
1984
+ }
1985
+ install() {
1986
+ if (this.installed) return;
1987
+ if (!this.opts.config.enabled) return;
1988
+ if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
1989
+ const w = globalThis.window;
1990
+ if (this.opts.config.onError) this.installOnErrorListener(w);
1991
+ if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
1992
+ if (this.opts.config.wrapFetch) this.installFetchWrap(w);
1993
+ if (this.opts.config.wrapXhr) this.installXhrWrap(w);
1994
+ if (this.opts.config.captureConsole) this.installConsoleWrap();
1995
+ this.installed = true;
1996
+ }
1997
+ uninstall() {
1998
+ for (const fn of this.cleanups.splice(0)) {
1999
+ try {
2000
+ fn();
2001
+ } catch {
2002
+ }
2003
+ }
2004
+ this.installed = false;
2005
+ }
2006
+ /**
2007
+ * Manual API. Either an Error instance or any unknown value (we
2008
+ * coerce). Returns silently — never throws.
2009
+ */
2010
+ captureError(error, options) {
2011
+ if (!this.opts.isConsented()) return;
2012
+ try {
2013
+ const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
2014
+ if (options?.context) captured.context = { ...captured.context, ...options.context };
2015
+ if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
2016
+ this.maybeReport(captured);
2017
+ } catch {
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Capture a non-error event as an issue. For "we hit a soft-warning
2022
+ * code path" / "deprecated API used" kinds of signals. Pairs with
2023
+ * Sentry's captureMessage().
2024
+ */
2025
+ captureMessage(message, level = "info") {
2026
+ if (!this.opts.isConsented()) return;
2027
+ try {
2028
+ const captured = {
2029
+ timestamp: Date.now(),
2030
+ kind: "error.message",
2031
+ level,
2032
+ message,
2033
+ errorType: null,
2034
+ frames: [],
2035
+ rawStack: null,
2036
+ filename: null,
2037
+ lineno: null,
2038
+ colno: null,
2039
+ fingerprint: fingerprintError(message, []),
2040
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2041
+ context: this.opts.getContext(),
2042
+ tags: this.opts.getTags()
2043
+ };
2044
+ this.maybeReport(captured);
2045
+ } catch {
2046
+ }
2047
+ }
2048
+ // ============================================================
2049
+ // Listener installation
2050
+ // ============================================================
2051
+ installOnErrorListener(w) {
2052
+ const handler = (event) => {
2053
+ if (this._reporting) return;
2054
+ if (!this.opts.isConsented()) return;
2055
+ try {
2056
+ this._reporting = true;
2057
+ const captured = this.buildFromErrorEvent(event);
2058
+ this.maybeReport(captured);
2059
+ } catch {
2060
+ } finally {
2061
+ this._reporting = false;
2062
+ }
2063
+ };
2064
+ w.addEventListener("error", handler, true);
2065
+ this.cleanups.push(() => w.removeEventListener("error", handler, true));
2066
+ }
2067
+ installRejectionListener(w) {
2068
+ const handler = (event) => {
2069
+ if (this._reporting) return;
2070
+ if (!this.opts.isConsented()) return;
2071
+ try {
2072
+ this._reporting = true;
2073
+ const captured = this.buildFromUnknown(
2074
+ event.reason,
2075
+ "error.unhandledrejection",
2076
+ "error"
2077
+ );
2078
+ this.maybeReport(captured);
2079
+ } catch {
2080
+ } finally {
2081
+ this._reporting = false;
2082
+ }
2083
+ };
2084
+ w.addEventListener("unhandledrejection", handler);
2085
+ this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
2086
+ }
2087
+ /**
2088
+ * Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
2089
+ * call this an "error" for 4xx (those are often expected — auth
2090
+ * required, validation failed). Only 5xx + network failures fire.
2091
+ */
2092
+ installFetchWrap(w) {
2093
+ const origFetch = w.fetch?.bind(w);
2094
+ if (!origFetch) return;
2095
+ const wrapped = async (...args) => {
2096
+ const input = args[0];
2097
+ const init = args[1] ?? {};
2098
+ const url = typeof input === "string" ? input : input?.url ?? "";
2099
+ const method = (init.method || "GET").toUpperCase();
2100
+ const start = Date.now();
2101
+ this.opts.breadcrumbs.add({
2102
+ timestamp: start,
2103
+ category: "http",
2104
+ message: `${method} ${url}`,
2105
+ data: { url, method }
2106
+ });
2107
+ try {
2108
+ const response = await origFetch(...args);
2109
+ if (response.status >= 500 && this.opts.isConsented()) {
2110
+ if (!url.includes("api.cross-deck.com")) {
2111
+ this.captureHttp({
2112
+ url,
2113
+ method,
2114
+ status: response.status,
2115
+ statusText: response.statusText
2116
+ });
2117
+ }
2118
+ }
2119
+ return response;
2120
+ } catch (err) {
2121
+ if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
2122
+ this.captureHttp({
2123
+ url,
2124
+ method,
2125
+ status: 0,
2126
+ statusText: err instanceof Error ? err.message : "network error"
2127
+ });
2128
+ }
2129
+ throw err;
2130
+ }
2131
+ };
2132
+ w.fetch = wrapped;
2133
+ this.cleanups.push(() => {
2134
+ if (w.fetch === wrapped) w.fetch = origFetch;
2135
+ });
2136
+ }
2137
+ /**
2138
+ * Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
2139
+ * hood, older bundlers). Same capture semantics as fetch.
2140
+ */
2141
+ installXhrWrap(w) {
2142
+ const xhrCtor = w.XMLHttpRequest;
2143
+ const proto = xhrCtor?.prototype;
2144
+ if (!proto) return;
2145
+ const origOpen = proto.open;
2146
+ const origSend = proto.send;
2147
+ const tracker = this;
2148
+ proto.open = function(method, url, ...rest) {
2149
+ this._cdMethod = method;
2150
+ this._cdUrl = url;
2151
+ return origOpen.apply(this, [method, url, ...rest]);
2152
+ };
2153
+ proto.send = function(body) {
2154
+ const xhr = this;
2155
+ const onLoad = () => {
2156
+ try {
2157
+ if (xhr.status >= 500 && tracker.opts.isConsented()) {
2158
+ const url = xhr._cdUrl ?? "";
2159
+ if (!url.includes("api.cross-deck.com")) {
2160
+ tracker.captureHttp({
2161
+ url,
2162
+ method: (xhr._cdMethod ?? "GET").toUpperCase(),
2163
+ status: xhr.status,
2164
+ statusText: xhr.statusText
2165
+ });
2166
+ }
2167
+ }
2168
+ } catch {
2169
+ }
2170
+ };
2171
+ xhr.addEventListener("loadend", onLoad);
2172
+ return origSend.apply(this, [body ?? null]);
2173
+ };
2174
+ this.cleanups.push(() => {
2175
+ proto.open = origOpen;
2176
+ proto.send = origSend;
2177
+ });
2178
+ }
2179
+ installConsoleWrap() {
2180
+ const console2 = globalThis.console;
2181
+ if (!console2) return;
2182
+ const orig = console2.error.bind(console2);
2183
+ console2.error = (...args) => {
2184
+ try {
2185
+ if (this.opts.isConsented()) {
2186
+ this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
2187
+ }
2188
+ } catch {
2189
+ }
2190
+ return orig(...args);
2191
+ };
2192
+ this.cleanups.push(() => {
2193
+ console2.error = orig;
2194
+ });
2195
+ }
2196
+ // ============================================================
2197
+ // Builders
2198
+ // ============================================================
2199
+ buildFromErrorEvent(event) {
2200
+ const err = event.error;
2201
+ const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2202
+ const stack = err instanceof Error ? err.stack ?? null : null;
2203
+ const frames = parseStack(stack);
2204
+ return {
2205
+ timestamp: Date.now(),
2206
+ kind: "error.unhandled",
2207
+ level: "error",
2208
+ message: String(message).slice(0, 1024),
2209
+ errorType: err instanceof Error ? err.name : null,
2210
+ frames,
2211
+ rawStack: stack,
2212
+ filename: event.filename || null,
2213
+ lineno: typeof event.lineno === "number" ? event.lineno : null,
2214
+ colno: typeof event.colno === "number" ? event.colno : null,
2215
+ fingerprint: fingerprintError(message, frames),
2216
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2217
+ context: this.opts.getContext(),
2218
+ tags: this.opts.getTags()
2219
+ };
2220
+ }
2221
+ buildFromUnknown(err, kind, level) {
2222
+ if (err instanceof Error) {
2223
+ const frames = parseStack(err.stack);
2224
+ return {
2225
+ timestamp: Date.now(),
2226
+ kind,
2227
+ level,
2228
+ message: String(err.message).slice(0, 1024),
2229
+ errorType: err.name,
2230
+ frames,
2231
+ rawStack: err.stack ?? null,
2232
+ filename: null,
2233
+ lineno: null,
2234
+ colno: null,
2235
+ fingerprint: fingerprintError(err.message, frames),
2236
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2237
+ context: this.opts.getContext(),
2238
+ tags: this.opts.getTags()
2239
+ };
2240
+ }
2241
+ const message = safeStringify2(err).slice(0, 1024);
2242
+ return {
2243
+ timestamp: Date.now(),
2244
+ kind,
2245
+ level,
2246
+ message,
2247
+ errorType: null,
2248
+ frames: [],
2249
+ rawStack: null,
2250
+ filename: null,
2251
+ lineno: null,
2252
+ colno: null,
2253
+ fingerprint: fingerprintError(message, []),
2254
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2255
+ context: this.opts.getContext(),
2256
+ tags: this.opts.getTags()
2257
+ };
2258
+ }
2259
+ captureHttp(info) {
2260
+ try {
2261
+ const message = `HTTP ${info.status} ${info.method} ${info.url}`;
2262
+ const captured = {
2263
+ timestamp: Date.now(),
2264
+ kind: "error.http",
2265
+ level: "error",
2266
+ message,
2267
+ errorType: `HTTPError`,
2268
+ frames: [],
2269
+ rawStack: null,
2270
+ filename: info.url,
2271
+ lineno: null,
2272
+ colno: null,
2273
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2274
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2275
+ context: this.opts.getContext(),
2276
+ tags: this.opts.getTags(),
2277
+ http: info
2278
+ };
2279
+ this.maybeReport(captured);
2280
+ } catch {
2281
+ }
2282
+ }
2283
+ // ============================================================
2284
+ // Reporting pipeline — filter / sample / rate-limit / send
2285
+ // ============================================================
2286
+ maybeReport(err) {
2287
+ if (this.sessionCount >= this.opts.config.maxPerSession) return;
2288
+ if (this.shouldIgnore(err)) return;
2289
+ if (!this.passesUrlGate(err)) return;
2290
+ if (!this.passesSample(err)) return;
2291
+ if (!this.passesRateLimit(err)) return;
2292
+ let finalErr = err;
2293
+ if (this.opts.beforeSend) {
2294
+ try {
2295
+ finalErr = this.opts.beforeSend(err);
2296
+ } catch {
2297
+ finalErr = err;
2298
+ }
2299
+ if (!finalErr) return;
2300
+ }
2301
+ this.sessionCount += 1;
2302
+ try {
2303
+ this.opts.report(finalErr);
2304
+ } catch {
2305
+ }
2306
+ }
2307
+ shouldIgnore(err) {
2308
+ for (const pat of this.opts.config.ignoreErrors) {
2309
+ if (typeof pat === "string" && err.message.includes(pat)) return true;
2310
+ if (pat instanceof RegExp && pat.test(err.message)) return true;
2311
+ }
2312
+ return false;
2313
+ }
2314
+ passesUrlGate(err) {
2315
+ const topFrame = err.frames.find((f) => f.filename) ?? null;
2316
+ const url = topFrame?.filename ?? err.filename ?? "";
2317
+ if (!url) return true;
2318
+ for (const pat of this.opts.config.denyUrls) {
2319
+ if (typeof pat === "string" && url.includes(pat)) return false;
2320
+ if (pat instanceof RegExp && pat.test(url)) return false;
2321
+ }
2322
+ if (this.opts.config.allowUrls.length > 0) {
2323
+ for (const pat of this.opts.config.allowUrls) {
2324
+ if (typeof pat === "string" && url.includes(pat)) return true;
2325
+ if (pat instanceof RegExp && pat.test(url)) return true;
2326
+ }
2327
+ return false;
2328
+ }
2329
+ return true;
2330
+ }
2331
+ passesSample(err) {
2332
+ if (this.opts.config.sampleRate >= 1) return true;
2333
+ if (this.opts.config.sampleRate <= 0) return false;
2334
+ const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
2335
+ return hashByte / 255 < this.opts.config.sampleRate;
2336
+ }
2337
+ passesRateLimit(err) {
2338
+ const windowMs = 6e4;
2339
+ const now = Date.now();
2340
+ const max = this.opts.config.maxPerFingerprintPerMinute;
2341
+ const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
2342
+ const fresh = arr.filter((t) => now - t < windowMs);
2343
+ if (fresh.length >= max) {
2344
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2345
+ return false;
2346
+ }
2347
+ fresh.push(now);
2348
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2349
+ return true;
2350
+ }
2351
+ };
2352
+ function safeStringify2(v) {
2353
+ if (v == null) return String(v);
2354
+ if (typeof v === "string") return v;
2355
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
2356
+ try {
2357
+ return JSON.stringify(v);
2358
+ } catch {
2359
+ return Object.prototype.toString.call(v);
2360
+ }
2361
+ }
2362
+
1827
2363
  // src/crossdeck.ts
1828
2364
  var CrossdeckClient = class {
1829
2365
  constructor() {
@@ -1957,6 +2493,7 @@ var CrossdeckClient = class {
1957
2493
  "Do Not Track detected \u2014 all tracking dimensions denied at init."
1958
2494
  );
1959
2495
  }
2496
+ const breadcrumbs = new BreadcrumbBuffer(50);
1960
2497
  this.state = {
1961
2498
  http,
1962
2499
  identity,
@@ -1964,6 +2501,11 @@ var CrossdeckClient = class {
1964
2501
  events,
1965
2502
  autoTracker: null,
1966
2503
  webVitals: null,
2504
+ errors: null,
2505
+ breadcrumbs,
2506
+ errorContext: {},
2507
+ errorTags: {},
2508
+ errorBeforeSend: null,
1967
2509
  superProps,
1968
2510
  consent,
1969
2511
  scrubPii: options.scrubPii !== false,
@@ -1996,6 +2538,19 @@ var CrossdeckClient = class {
1996
2538
  this.state.webVitals = vitals;
1997
2539
  vitals.install();
1998
2540
  }
2541
+ if (autoTrack.errors) {
2542
+ const tracker = new ErrorTracker({
2543
+ config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
2544
+ breadcrumbs,
2545
+ report: (err) => this.reportError(err),
2546
+ getContext: () => ({ ...this.state.errorContext }),
2547
+ getTags: () => ({ ...this.state.errorTags }),
2548
+ beforeSend: this.state.errorBeforeSend,
2549
+ isConsented: () => this.state.consent.errors
2550
+ });
2551
+ this.state.errors = tracker;
2552
+ tracker.install();
2553
+ }
1999
2554
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
2000
2555
  void this.flush({ keepalive: true }).catch(() => void 0);
2001
2556
  });
@@ -2163,6 +2718,108 @@ var CrossdeckClient = class {
2163
2718
  }
2164
2719
  return this.state.consent.get();
2165
2720
  }
2721
+ // ============================================================
2722
+ // Error capture surface (v1.0.0+)
2723
+ // ============================================================
2724
+ /**
2725
+ * Manually capture an error from a try/catch block.
2726
+ *
2727
+ * try { …risky… } catch (err) {
2728
+ * Crossdeck.captureError(err, { context: { plan: "pro" } });
2729
+ * }
2730
+ *
2731
+ * The error is shipped through the same event queue as analytics
2732
+ * (durable, retried, rate-limited per fingerprint). Sends are gated
2733
+ * by `consent.errors`. Returns silently — never throws, even if the
2734
+ * SDK isn't initialised yet.
2735
+ */
2736
+ captureError(error, options) {
2737
+ if (!this.state?.errors) return;
2738
+ this.state.errors.captureError(error, options);
2739
+ }
2740
+ /**
2741
+ * Capture a non-error event you want to surface as an issue
2742
+ * ("deprecated path hit", "we entered the slow code path"). Sentry
2743
+ * captureMessage pattern. Returns silently if not initialised.
2744
+ */
2745
+ captureMessage(message, level = "info") {
2746
+ if (!this.state?.errors) return;
2747
+ this.state.errors.captureMessage(message, level);
2748
+ }
2749
+ /**
2750
+ * Attach a tag to every subsequent error report. Tags are key/value
2751
+ * strings (Sentry pattern): `setTag("flow", "checkout")` → every
2752
+ * error from this point on carries `tags.flow === "checkout"`.
2753
+ */
2754
+ setTag(key, value) {
2755
+ if (!this.state) return;
2756
+ this.state.errorTags[key] = value;
2757
+ }
2758
+ /** Bulk-set tags. Merges with existing tags. */
2759
+ setTags(tags) {
2760
+ if (!this.state) return;
2761
+ Object.assign(this.state.errorTags, tags);
2762
+ }
2763
+ /**
2764
+ * Attach a structured context blob to every subsequent error report.
2765
+ * Unlike tags (flat key/value), context is a named bag of arbitrary
2766
+ * data: `setContext("cart", { items: 3, total: 42.99 })`.
2767
+ */
2768
+ setContext(name, data) {
2769
+ if (!this.state) return;
2770
+ this.state.errorContext[name] = data;
2771
+ }
2772
+ /**
2773
+ * Add a custom breadcrumb to the rolling buffer. Useful for marking
2774
+ * domain-meaningful moments ("user opened paywall") that aren't
2775
+ * already auto-captured. The buffer caps at 50 entries; old ones
2776
+ * evict.
2777
+ */
2778
+ addBreadcrumb(crumb) {
2779
+ if (!this.state) return;
2780
+ this.state.breadcrumbs.add(crumb);
2781
+ }
2782
+ /**
2783
+ * Install a pre-send hook for errors. Return null to drop, or a
2784
+ * modified CapturedError to scrub / rewrite. Sentry's beforeSend
2785
+ * pattern — the only way to redact app-specific PII (auth tokens
2786
+ * in URLs, etc.) before the report leaves the browser.
2787
+ */
2788
+ setErrorBeforeSend(hook) {
2789
+ if (!this.state) return;
2790
+ this.state.errorBeforeSend = hook;
2791
+ }
2792
+ /**
2793
+ * Internal: turn a CapturedError into a Crossdeck event and enqueue
2794
+ * it. Goes through the same queue / persistence / consent / scrub
2795
+ * pipeline as analytics events.
2796
+ */
2797
+ reportError(err) {
2798
+ const properties = {
2799
+ // Identifiers
2800
+ fingerprint: err.fingerprint,
2801
+ level: err.level,
2802
+ // Error shape
2803
+ errorType: err.errorType,
2804
+ message: err.message,
2805
+ // Stack
2806
+ stack: err.rawStack ?? void 0,
2807
+ frames: err.frames,
2808
+ filename: err.filename ?? void 0,
2809
+ lineno: err.lineno ?? void 0,
2810
+ colno: err.colno ?? void 0,
2811
+ // Context
2812
+ tags: err.tags,
2813
+ context: err.context,
2814
+ breadcrumbs: err.breadcrumbs,
2815
+ // HTTP (only when applicable)
2816
+ http: err.http
2817
+ };
2818
+ for (const k of Object.keys(properties)) {
2819
+ if (properties[k] === void 0) delete properties[k];
2820
+ }
2821
+ this.track(err.kind, properties);
2822
+ }
2166
2823
  /**
2167
2824
  * GDPR/CCPA "right to be forgotten" — calls the backend's
2168
2825
  * /v1/identity/forget endpoint to schedule a server-side deletion of
@@ -2274,8 +2931,9 @@ var CrossdeckClient = class {
2274
2931
  message: "track(name) requires a non-empty name."
2275
2932
  });
2276
2933
  }
2934
+ const isError = name.startsWith("error.");
2277
2935
  const isWebVital = name.startsWith("webvitals.");
2278
- const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2936
+ const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
2279
2937
  if (!consentGateOk) {
2280
2938
  if (s.debug.enabled) {
2281
2939
  s.debug.emit(
@@ -2351,6 +3009,18 @@ var CrossdeckClient = class {
2351
3009
  };
2352
3010
  Object.assign(event, this.identityHintForEvent());
2353
3011
  s.events.enqueue(event);
3012
+ if (!isError && !isWebVital) {
3013
+ const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
3014
+ s.breadcrumbs.add({
3015
+ timestamp: event.timestamp,
3016
+ category,
3017
+ message: name,
3018
+ // Strip the device-info / session bloat from the breadcrumb
3019
+ // payload — only the caller-supplied properties belong in
3020
+ // the user-readable trail.
3021
+ data: properties ? { ...properties } : void 0
3022
+ });
3023
+ }
2354
3024
  }
2355
3025
  /**
2356
3026
  * Force-flush queued events. Useful to call from page-unload handlers.
@@ -2450,6 +3120,9 @@ var CrossdeckClient = class {
2450
3120
  this.state.entitlements.clear();
2451
3121
  this.state.events.reset();
2452
3122
  this.state.superProps.clear();
3123
+ this.state.breadcrumbs.clear();
3124
+ this.state.errorContext = {};
3125
+ this.state.errorTags = {};
2453
3126
  this.state.developerUserId = null;
2454
3127
  if (this.state.autoTracker) {
2455
3128
  const tracker = new AutoTracker(
@@ -2592,7 +3265,8 @@ function resolveAutoTrack(input) {
2592
3265
  pageViews: false,
2593
3266
  deviceInfo: false,
2594
3267
  clicks: false,
2595
- webVitals: false
3268
+ webVitals: false,
3269
+ errors: false
2596
3270
  };
2597
3271
  }
2598
3272
  if (input === void 0 || input === true) {
@@ -2603,7 +3277,8 @@ function resolveAutoTrack(input) {
2603
3277
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
2604
3278
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
2605
3279
  clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2606
- webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
3280
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
3281
+ errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
2607
3282
  };
2608
3283
  }
2609
3284
  function installUnloadFlush(onUnload) {