@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/react.mjs CHANGED
@@ -68,7 +68,7 @@ function typeMapForStatus(status) {
68
68
 
69
69
  // src/http.ts
70
70
  var SDK_NAME = "@cross-deck/web";
71
- var SDK_VERSION = "0.10.0";
71
+ var SDK_VERSION = "1.0.0";
72
72
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
73
  var DEFAULT_TIMEOUT_MS = 15e3;
74
74
  var HttpClient = class {
@@ -892,7 +892,8 @@ var DEFAULT_AUTO_TRACK = {
892
892
  pageViews: true,
893
893
  deviceInfo: true,
894
894
  clicks: true,
895
- webVitals: true
895
+ webVitals: true,
896
+ errors: true
896
897
  };
897
898
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
898
899
  var EMPTY_ACQUISITION = {
@@ -1793,6 +1794,541 @@ function scrubPiiFromProperties(properties) {
1793
1794
  return out;
1794
1795
  }
1795
1796
 
1797
+ // src/breadcrumbs.ts
1798
+ var BreadcrumbBuffer = class {
1799
+ constructor(maxSize = 50) {
1800
+ this.maxSize = maxSize;
1801
+ this.items = [];
1802
+ }
1803
+ add(crumb) {
1804
+ this.items.push(crumb);
1805
+ if (this.items.length > this.maxSize) {
1806
+ this.items.shift();
1807
+ }
1808
+ }
1809
+ /** Defensive copy — caller can read freely without mutating buffer state. */
1810
+ snapshot() {
1811
+ return this.items.slice();
1812
+ }
1813
+ clear() {
1814
+ this.items = [];
1815
+ }
1816
+ get size() {
1817
+ return this.items.length;
1818
+ }
1819
+ };
1820
+
1821
+ // src/stack-parser.ts
1822
+ function parseStack(stack) {
1823
+ if (!stack || typeof stack !== "string") return [];
1824
+ const lines = stack.split("\n");
1825
+ const frames = [];
1826
+ for (const line of lines) {
1827
+ const trimmed = line.trim();
1828
+ if (!trimmed) continue;
1829
+ const frame = parseLine(trimmed);
1830
+ if (frame) frames.push(frame);
1831
+ }
1832
+ return frames;
1833
+ }
1834
+ function parseLine(line) {
1835
+ let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
1836
+ if (m) {
1837
+ return buildFrame({
1838
+ function: m[1],
1839
+ filename: m[2],
1840
+ lineno: parseInt(m[3], 10),
1841
+ colno: parseInt(m[4], 10),
1842
+ raw: line
1843
+ });
1844
+ }
1845
+ m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
1846
+ if (m) {
1847
+ return buildFrame({
1848
+ function: "?",
1849
+ filename: m[1],
1850
+ lineno: parseInt(m[2], 10),
1851
+ colno: parseInt(m[3], 10),
1852
+ raw: line
1853
+ });
1854
+ }
1855
+ m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
1856
+ if (m) {
1857
+ return buildFrame({
1858
+ function: m[1] || "?",
1859
+ filename: m[2],
1860
+ lineno: parseInt(m[3], 10),
1861
+ colno: parseInt(m[4], 10),
1862
+ raw: line
1863
+ });
1864
+ }
1865
+ if (/^\w*Error/.test(line) || !line.includes(":")) {
1866
+ return null;
1867
+ }
1868
+ return {
1869
+ function: "?",
1870
+ filename: "",
1871
+ lineno: 0,
1872
+ colno: 0,
1873
+ in_app: true,
1874
+ raw: line
1875
+ };
1876
+ }
1877
+ function buildFrame(input) {
1878
+ return {
1879
+ function: input.function || "?",
1880
+ filename: input.filename,
1881
+ lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
1882
+ colno: Number.isFinite(input.colno) ? input.colno : 0,
1883
+ in_app: isInAppFrame(input.filename),
1884
+ raw: input.raw
1885
+ };
1886
+ }
1887
+ function isInAppFrame(filename) {
1888
+ if (!filename) return true;
1889
+ if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
1890
+ if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
1891
+ if (/\bunpkg\.com\b/.test(filename)) return false;
1892
+ if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
1893
+ if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
1894
+ if (/\b@cross-deck\/web\b/.test(filename)) return false;
1895
+ if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1896
+ return true;
1897
+ }
1898
+ function fingerprintError(message, frames) {
1899
+ const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1900
+ const key = [
1901
+ (message || "").slice(0, 200),
1902
+ ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1903
+ ].join("|");
1904
+ return djb2Hex(key);
1905
+ }
1906
+ function djb2Hex(input) {
1907
+ let h = 5381;
1908
+ for (let i = 0; i < input.length; i++) {
1909
+ h = (h << 5) + h + input.charCodeAt(i) | 0;
1910
+ }
1911
+ return (h >>> 0).toString(16).padStart(8, "0");
1912
+ }
1913
+
1914
+ // src/error-capture.ts
1915
+ var DEFAULT_ERROR_CAPTURE = {
1916
+ enabled: true,
1917
+ onError: true,
1918
+ onUnhandledRejection: true,
1919
+ wrapFetch: true,
1920
+ wrapXhr: true,
1921
+ captureConsole: false,
1922
+ ignoreErrors: [
1923
+ // Classic browser noise. These aren't application bugs.
1924
+ "ResizeObserver loop limit exceeded",
1925
+ "ResizeObserver loop completed with undelivered notifications",
1926
+ "Non-Error promise rejection captured",
1927
+ // Cross-origin script errors that the browser strips — no info,
1928
+ // no way to act on them, just noise.
1929
+ "Script error.",
1930
+ "Script error"
1931
+ ],
1932
+ allowUrls: [],
1933
+ denyUrls: [
1934
+ // Common third-party extensions that pollute error streams.
1935
+ /^chrome-extension:\/\//,
1936
+ /^moz-extension:\/\//,
1937
+ /^safari-extension:\/\//,
1938
+ /^webkit-extension:\/\//,
1939
+ /^safari-web-extension:\/\//
1940
+ ],
1941
+ sampleRate: 1,
1942
+ maxPerFingerprintPerMinute: 5,
1943
+ maxPerSession: 100
1944
+ };
1945
+ var ErrorTracker = class {
1946
+ constructor(opts) {
1947
+ this.opts = opts;
1948
+ this.installed = false;
1949
+ this.cleanups = [];
1950
+ this._reporting = false;
1951
+ this.sessionCount = 0;
1952
+ this.fingerprintWindow = /* @__PURE__ */ new Map();
1953
+ }
1954
+ install() {
1955
+ if (this.installed) return;
1956
+ if (!this.opts.config.enabled) return;
1957
+ if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
1958
+ const w = globalThis.window;
1959
+ if (this.opts.config.onError) this.installOnErrorListener(w);
1960
+ if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
1961
+ if (this.opts.config.wrapFetch) this.installFetchWrap(w);
1962
+ if (this.opts.config.wrapXhr) this.installXhrWrap(w);
1963
+ if (this.opts.config.captureConsole) this.installConsoleWrap();
1964
+ this.installed = true;
1965
+ }
1966
+ uninstall() {
1967
+ for (const fn of this.cleanups.splice(0)) {
1968
+ try {
1969
+ fn();
1970
+ } catch {
1971
+ }
1972
+ }
1973
+ this.installed = false;
1974
+ }
1975
+ /**
1976
+ * Manual API. Either an Error instance or any unknown value (we
1977
+ * coerce). Returns silently — never throws.
1978
+ */
1979
+ captureError(error, options) {
1980
+ if (!this.opts.isConsented()) return;
1981
+ try {
1982
+ const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
1983
+ if (options?.context) captured.context = { ...captured.context, ...options.context };
1984
+ if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
1985
+ this.maybeReport(captured);
1986
+ } catch {
1987
+ }
1988
+ }
1989
+ /**
1990
+ * Capture a non-error event as an issue. For "we hit a soft-warning
1991
+ * code path" / "deprecated API used" kinds of signals. Pairs with
1992
+ * Sentry's captureMessage().
1993
+ */
1994
+ captureMessage(message, level = "info") {
1995
+ if (!this.opts.isConsented()) return;
1996
+ try {
1997
+ const captured = {
1998
+ timestamp: Date.now(),
1999
+ kind: "error.message",
2000
+ level,
2001
+ message,
2002
+ errorType: null,
2003
+ frames: [],
2004
+ rawStack: null,
2005
+ filename: null,
2006
+ lineno: null,
2007
+ colno: null,
2008
+ fingerprint: fingerprintError(message, []),
2009
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2010
+ context: this.opts.getContext(),
2011
+ tags: this.opts.getTags()
2012
+ };
2013
+ this.maybeReport(captured);
2014
+ } catch {
2015
+ }
2016
+ }
2017
+ // ============================================================
2018
+ // Listener installation
2019
+ // ============================================================
2020
+ installOnErrorListener(w) {
2021
+ const handler = (event) => {
2022
+ if (this._reporting) return;
2023
+ if (!this.opts.isConsented()) return;
2024
+ try {
2025
+ this._reporting = true;
2026
+ const captured = this.buildFromErrorEvent(event);
2027
+ this.maybeReport(captured);
2028
+ } catch {
2029
+ } finally {
2030
+ this._reporting = false;
2031
+ }
2032
+ };
2033
+ w.addEventListener("error", handler, true);
2034
+ this.cleanups.push(() => w.removeEventListener("error", handler, true));
2035
+ }
2036
+ installRejectionListener(w) {
2037
+ const handler = (event) => {
2038
+ if (this._reporting) return;
2039
+ if (!this.opts.isConsented()) return;
2040
+ try {
2041
+ this._reporting = true;
2042
+ const captured = this.buildFromUnknown(
2043
+ event.reason,
2044
+ "error.unhandledrejection",
2045
+ "error"
2046
+ );
2047
+ this.maybeReport(captured);
2048
+ } catch {
2049
+ } finally {
2050
+ this._reporting = false;
2051
+ }
2052
+ };
2053
+ w.addEventListener("unhandledrejection", handler);
2054
+ this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
2055
+ }
2056
+ /**
2057
+ * Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
2058
+ * call this an "error" for 4xx (those are often expected — auth
2059
+ * required, validation failed). Only 5xx + network failures fire.
2060
+ */
2061
+ installFetchWrap(w) {
2062
+ const origFetch = w.fetch?.bind(w);
2063
+ if (!origFetch) return;
2064
+ const wrapped = async (...args) => {
2065
+ const input = args[0];
2066
+ const init = args[1] ?? {};
2067
+ const url = typeof input === "string" ? input : input?.url ?? "";
2068
+ const method = (init.method || "GET").toUpperCase();
2069
+ const start = Date.now();
2070
+ this.opts.breadcrumbs.add({
2071
+ timestamp: start,
2072
+ category: "http",
2073
+ message: `${method} ${url}`,
2074
+ data: { url, method }
2075
+ });
2076
+ try {
2077
+ const response = await origFetch(...args);
2078
+ if (response.status >= 500 && this.opts.isConsented()) {
2079
+ if (!url.includes("api.cross-deck.com")) {
2080
+ this.captureHttp({
2081
+ url,
2082
+ method,
2083
+ status: response.status,
2084
+ statusText: response.statusText
2085
+ });
2086
+ }
2087
+ }
2088
+ return response;
2089
+ } catch (err) {
2090
+ if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
2091
+ this.captureHttp({
2092
+ url,
2093
+ method,
2094
+ status: 0,
2095
+ statusText: err instanceof Error ? err.message : "network error"
2096
+ });
2097
+ }
2098
+ throw err;
2099
+ }
2100
+ };
2101
+ w.fetch = wrapped;
2102
+ this.cleanups.push(() => {
2103
+ if (w.fetch === wrapped) w.fetch = origFetch;
2104
+ });
2105
+ }
2106
+ /**
2107
+ * Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
2108
+ * hood, older bundlers). Same capture semantics as fetch.
2109
+ */
2110
+ installXhrWrap(w) {
2111
+ const xhrCtor = w.XMLHttpRequest;
2112
+ const proto = xhrCtor?.prototype;
2113
+ if (!proto) return;
2114
+ const origOpen = proto.open;
2115
+ const origSend = proto.send;
2116
+ const tracker = this;
2117
+ proto.open = function(method, url, ...rest) {
2118
+ this._cdMethod = method;
2119
+ this._cdUrl = url;
2120
+ return origOpen.apply(this, [method, url, ...rest]);
2121
+ };
2122
+ proto.send = function(body) {
2123
+ const xhr = this;
2124
+ const onLoad = () => {
2125
+ try {
2126
+ if (xhr.status >= 500 && tracker.opts.isConsented()) {
2127
+ const url = xhr._cdUrl ?? "";
2128
+ if (!url.includes("api.cross-deck.com")) {
2129
+ tracker.captureHttp({
2130
+ url,
2131
+ method: (xhr._cdMethod ?? "GET").toUpperCase(),
2132
+ status: xhr.status,
2133
+ statusText: xhr.statusText
2134
+ });
2135
+ }
2136
+ }
2137
+ } catch {
2138
+ }
2139
+ };
2140
+ xhr.addEventListener("loadend", onLoad);
2141
+ return origSend.apply(this, [body ?? null]);
2142
+ };
2143
+ this.cleanups.push(() => {
2144
+ proto.open = origOpen;
2145
+ proto.send = origSend;
2146
+ });
2147
+ }
2148
+ installConsoleWrap() {
2149
+ const console2 = globalThis.console;
2150
+ if (!console2) return;
2151
+ const orig = console2.error.bind(console2);
2152
+ console2.error = (...args) => {
2153
+ try {
2154
+ if (this.opts.isConsented()) {
2155
+ this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
2156
+ }
2157
+ } catch {
2158
+ }
2159
+ return orig(...args);
2160
+ };
2161
+ this.cleanups.push(() => {
2162
+ console2.error = orig;
2163
+ });
2164
+ }
2165
+ // ============================================================
2166
+ // Builders
2167
+ // ============================================================
2168
+ buildFromErrorEvent(event) {
2169
+ const err = event.error;
2170
+ const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2171
+ const stack = err instanceof Error ? err.stack ?? null : null;
2172
+ const frames = parseStack(stack);
2173
+ return {
2174
+ timestamp: Date.now(),
2175
+ kind: "error.unhandled",
2176
+ level: "error",
2177
+ message: String(message).slice(0, 1024),
2178
+ errorType: err instanceof Error ? err.name : null,
2179
+ frames,
2180
+ rawStack: stack,
2181
+ filename: event.filename || null,
2182
+ lineno: typeof event.lineno === "number" ? event.lineno : null,
2183
+ colno: typeof event.colno === "number" ? event.colno : null,
2184
+ fingerprint: fingerprintError(message, frames),
2185
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2186
+ context: this.opts.getContext(),
2187
+ tags: this.opts.getTags()
2188
+ };
2189
+ }
2190
+ buildFromUnknown(err, kind, level) {
2191
+ if (err instanceof Error) {
2192
+ const frames = parseStack(err.stack);
2193
+ return {
2194
+ timestamp: Date.now(),
2195
+ kind,
2196
+ level,
2197
+ message: String(err.message).slice(0, 1024),
2198
+ errorType: err.name,
2199
+ frames,
2200
+ rawStack: err.stack ?? null,
2201
+ filename: null,
2202
+ lineno: null,
2203
+ colno: null,
2204
+ fingerprint: fingerprintError(err.message, frames),
2205
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2206
+ context: this.opts.getContext(),
2207
+ tags: this.opts.getTags()
2208
+ };
2209
+ }
2210
+ const message = safeStringify2(err).slice(0, 1024);
2211
+ return {
2212
+ timestamp: Date.now(),
2213
+ kind,
2214
+ level,
2215
+ message,
2216
+ errorType: null,
2217
+ frames: [],
2218
+ rawStack: null,
2219
+ filename: null,
2220
+ lineno: null,
2221
+ colno: null,
2222
+ fingerprint: fingerprintError(message, []),
2223
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2224
+ context: this.opts.getContext(),
2225
+ tags: this.opts.getTags()
2226
+ };
2227
+ }
2228
+ captureHttp(info) {
2229
+ try {
2230
+ const message = `HTTP ${info.status} ${info.method} ${info.url}`;
2231
+ const captured = {
2232
+ timestamp: Date.now(),
2233
+ kind: "error.http",
2234
+ level: "error",
2235
+ message,
2236
+ errorType: `HTTPError`,
2237
+ frames: [],
2238
+ rawStack: null,
2239
+ filename: info.url,
2240
+ lineno: null,
2241
+ colno: null,
2242
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2243
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2244
+ context: this.opts.getContext(),
2245
+ tags: this.opts.getTags(),
2246
+ http: info
2247
+ };
2248
+ this.maybeReport(captured);
2249
+ } catch {
2250
+ }
2251
+ }
2252
+ // ============================================================
2253
+ // Reporting pipeline — filter / sample / rate-limit / send
2254
+ // ============================================================
2255
+ maybeReport(err) {
2256
+ if (this.sessionCount >= this.opts.config.maxPerSession) return;
2257
+ if (this.shouldIgnore(err)) return;
2258
+ if (!this.passesUrlGate(err)) return;
2259
+ if (!this.passesSample(err)) return;
2260
+ if (!this.passesRateLimit(err)) return;
2261
+ let finalErr = err;
2262
+ if (this.opts.beforeSend) {
2263
+ try {
2264
+ finalErr = this.opts.beforeSend(err);
2265
+ } catch {
2266
+ finalErr = err;
2267
+ }
2268
+ if (!finalErr) return;
2269
+ }
2270
+ this.sessionCount += 1;
2271
+ try {
2272
+ this.opts.report(finalErr);
2273
+ } catch {
2274
+ }
2275
+ }
2276
+ shouldIgnore(err) {
2277
+ for (const pat of this.opts.config.ignoreErrors) {
2278
+ if (typeof pat === "string" && err.message.includes(pat)) return true;
2279
+ if (pat instanceof RegExp && pat.test(err.message)) return true;
2280
+ }
2281
+ return false;
2282
+ }
2283
+ passesUrlGate(err) {
2284
+ const topFrame = err.frames.find((f) => f.filename) ?? null;
2285
+ const url = topFrame?.filename ?? err.filename ?? "";
2286
+ if (!url) return true;
2287
+ for (const pat of this.opts.config.denyUrls) {
2288
+ if (typeof pat === "string" && url.includes(pat)) return false;
2289
+ if (pat instanceof RegExp && pat.test(url)) return false;
2290
+ }
2291
+ if (this.opts.config.allowUrls.length > 0) {
2292
+ for (const pat of this.opts.config.allowUrls) {
2293
+ if (typeof pat === "string" && url.includes(pat)) return true;
2294
+ if (pat instanceof RegExp && pat.test(url)) return true;
2295
+ }
2296
+ return false;
2297
+ }
2298
+ return true;
2299
+ }
2300
+ passesSample(err) {
2301
+ if (this.opts.config.sampleRate >= 1) return true;
2302
+ if (this.opts.config.sampleRate <= 0) return false;
2303
+ const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
2304
+ return hashByte / 255 < this.opts.config.sampleRate;
2305
+ }
2306
+ passesRateLimit(err) {
2307
+ const windowMs = 6e4;
2308
+ const now = Date.now();
2309
+ const max = this.opts.config.maxPerFingerprintPerMinute;
2310
+ const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
2311
+ const fresh = arr.filter((t) => now - t < windowMs);
2312
+ if (fresh.length >= max) {
2313
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2314
+ return false;
2315
+ }
2316
+ fresh.push(now);
2317
+ this.fingerprintWindow.set(err.fingerprint, fresh);
2318
+ return true;
2319
+ }
2320
+ };
2321
+ function safeStringify2(v) {
2322
+ if (v == null) return String(v);
2323
+ if (typeof v === "string") return v;
2324
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
2325
+ try {
2326
+ return JSON.stringify(v);
2327
+ } catch {
2328
+ return Object.prototype.toString.call(v);
2329
+ }
2330
+ }
2331
+
1796
2332
  // src/crossdeck.ts
1797
2333
  var CrossdeckClient = class {
1798
2334
  constructor() {
@@ -1926,6 +2462,7 @@ var CrossdeckClient = class {
1926
2462
  "Do Not Track detected \u2014 all tracking dimensions denied at init."
1927
2463
  );
1928
2464
  }
2465
+ const breadcrumbs = new BreadcrumbBuffer(50);
1929
2466
  this.state = {
1930
2467
  http,
1931
2468
  identity,
@@ -1933,6 +2470,11 @@ var CrossdeckClient = class {
1933
2470
  events,
1934
2471
  autoTracker: null,
1935
2472
  webVitals: null,
2473
+ errors: null,
2474
+ breadcrumbs,
2475
+ errorContext: {},
2476
+ errorTags: {},
2477
+ errorBeforeSend: null,
1936
2478
  superProps,
1937
2479
  consent,
1938
2480
  scrubPii: options.scrubPii !== false,
@@ -1965,6 +2507,19 @@ var CrossdeckClient = class {
1965
2507
  this.state.webVitals = vitals;
1966
2508
  vitals.install();
1967
2509
  }
2510
+ if (autoTrack.errors) {
2511
+ const tracker = new ErrorTracker({
2512
+ config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
2513
+ breadcrumbs,
2514
+ report: (err) => this.reportError(err),
2515
+ getContext: () => ({ ...this.state.errorContext }),
2516
+ getTags: () => ({ ...this.state.errorTags }),
2517
+ beforeSend: this.state.errorBeforeSend,
2518
+ isConsented: () => this.state.consent.errors
2519
+ });
2520
+ this.state.errors = tracker;
2521
+ tracker.install();
2522
+ }
1968
2523
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1969
2524
  void this.flush({ keepalive: true }).catch(() => void 0);
1970
2525
  });
@@ -2132,6 +2687,108 @@ var CrossdeckClient = class {
2132
2687
  }
2133
2688
  return this.state.consent.get();
2134
2689
  }
2690
+ // ============================================================
2691
+ // Error capture surface (v1.0.0+)
2692
+ // ============================================================
2693
+ /**
2694
+ * Manually capture an error from a try/catch block.
2695
+ *
2696
+ * try { …risky… } catch (err) {
2697
+ * Crossdeck.captureError(err, { context: { plan: "pro" } });
2698
+ * }
2699
+ *
2700
+ * The error is shipped through the same event queue as analytics
2701
+ * (durable, retried, rate-limited per fingerprint). Sends are gated
2702
+ * by `consent.errors`. Returns silently — never throws, even if the
2703
+ * SDK isn't initialised yet.
2704
+ */
2705
+ captureError(error, options) {
2706
+ if (!this.state?.errors) return;
2707
+ this.state.errors.captureError(error, options);
2708
+ }
2709
+ /**
2710
+ * Capture a non-error event you want to surface as an issue
2711
+ * ("deprecated path hit", "we entered the slow code path"). Sentry
2712
+ * captureMessage pattern. Returns silently if not initialised.
2713
+ */
2714
+ captureMessage(message, level = "info") {
2715
+ if (!this.state?.errors) return;
2716
+ this.state.errors.captureMessage(message, level);
2717
+ }
2718
+ /**
2719
+ * Attach a tag to every subsequent error report. Tags are key/value
2720
+ * strings (Sentry pattern): `setTag("flow", "checkout")` → every
2721
+ * error from this point on carries `tags.flow === "checkout"`.
2722
+ */
2723
+ setTag(key, value) {
2724
+ if (!this.state) return;
2725
+ this.state.errorTags[key] = value;
2726
+ }
2727
+ /** Bulk-set tags. Merges with existing tags. */
2728
+ setTags(tags) {
2729
+ if (!this.state) return;
2730
+ Object.assign(this.state.errorTags, tags);
2731
+ }
2732
+ /**
2733
+ * Attach a structured context blob to every subsequent error report.
2734
+ * Unlike tags (flat key/value), context is a named bag of arbitrary
2735
+ * data: `setContext("cart", { items: 3, total: 42.99 })`.
2736
+ */
2737
+ setContext(name, data) {
2738
+ if (!this.state) return;
2739
+ this.state.errorContext[name] = data;
2740
+ }
2741
+ /**
2742
+ * Add a custom breadcrumb to the rolling buffer. Useful for marking
2743
+ * domain-meaningful moments ("user opened paywall") that aren't
2744
+ * already auto-captured. The buffer caps at 50 entries; old ones
2745
+ * evict.
2746
+ */
2747
+ addBreadcrumb(crumb) {
2748
+ if (!this.state) return;
2749
+ this.state.breadcrumbs.add(crumb);
2750
+ }
2751
+ /**
2752
+ * Install a pre-send hook for errors. Return null to drop, or a
2753
+ * modified CapturedError to scrub / rewrite. Sentry's beforeSend
2754
+ * pattern — the only way to redact app-specific PII (auth tokens
2755
+ * in URLs, etc.) before the report leaves the browser.
2756
+ */
2757
+ setErrorBeforeSend(hook) {
2758
+ if (!this.state) return;
2759
+ this.state.errorBeforeSend = hook;
2760
+ }
2761
+ /**
2762
+ * Internal: turn a CapturedError into a Crossdeck event and enqueue
2763
+ * it. Goes through the same queue / persistence / consent / scrub
2764
+ * pipeline as analytics events.
2765
+ */
2766
+ reportError(err) {
2767
+ const properties = {
2768
+ // Identifiers
2769
+ fingerprint: err.fingerprint,
2770
+ level: err.level,
2771
+ // Error shape
2772
+ errorType: err.errorType,
2773
+ message: err.message,
2774
+ // Stack
2775
+ stack: err.rawStack ?? void 0,
2776
+ frames: err.frames,
2777
+ filename: err.filename ?? void 0,
2778
+ lineno: err.lineno ?? void 0,
2779
+ colno: err.colno ?? void 0,
2780
+ // Context
2781
+ tags: err.tags,
2782
+ context: err.context,
2783
+ breadcrumbs: err.breadcrumbs,
2784
+ // HTTP (only when applicable)
2785
+ http: err.http
2786
+ };
2787
+ for (const k of Object.keys(properties)) {
2788
+ if (properties[k] === void 0) delete properties[k];
2789
+ }
2790
+ this.track(err.kind, properties);
2791
+ }
2135
2792
  /**
2136
2793
  * GDPR/CCPA "right to be forgotten" — calls the backend's
2137
2794
  * /v1/identity/forget endpoint to schedule a server-side deletion of
@@ -2243,8 +2900,9 @@ var CrossdeckClient = class {
2243
2900
  message: "track(name) requires a non-empty name."
2244
2901
  });
2245
2902
  }
2903
+ const isError = name.startsWith("error.");
2246
2904
  const isWebVital = name.startsWith("webvitals.");
2247
- const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2905
+ const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
2248
2906
  if (!consentGateOk) {
2249
2907
  if (s.debug.enabled) {
2250
2908
  s.debug.emit(
@@ -2320,6 +2978,18 @@ var CrossdeckClient = class {
2320
2978
  };
2321
2979
  Object.assign(event, this.identityHintForEvent());
2322
2980
  s.events.enqueue(event);
2981
+ if (!isError && !isWebVital) {
2982
+ const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
2983
+ s.breadcrumbs.add({
2984
+ timestamp: event.timestamp,
2985
+ category,
2986
+ message: name,
2987
+ // Strip the device-info / session bloat from the breadcrumb
2988
+ // payload — only the caller-supplied properties belong in
2989
+ // the user-readable trail.
2990
+ data: properties ? { ...properties } : void 0
2991
+ });
2992
+ }
2323
2993
  }
2324
2994
  /**
2325
2995
  * Force-flush queued events. Useful to call from page-unload handlers.
@@ -2419,6 +3089,9 @@ var CrossdeckClient = class {
2419
3089
  this.state.entitlements.clear();
2420
3090
  this.state.events.reset();
2421
3091
  this.state.superProps.clear();
3092
+ this.state.breadcrumbs.clear();
3093
+ this.state.errorContext = {};
3094
+ this.state.errorTags = {};
2422
3095
  this.state.developerUserId = null;
2423
3096
  if (this.state.autoTracker) {
2424
3097
  const tracker = new AutoTracker(
@@ -2561,7 +3234,8 @@ function resolveAutoTrack(input) {
2561
3234
  pageViews: false,
2562
3235
  deviceInfo: false,
2563
3236
  clicks: false,
2564
- webVitals: false
3237
+ webVitals: false,
3238
+ errors: false
2565
3239
  };
2566
3240
  }
2567
3241
  if (input === void 0 || input === true) {
@@ -2572,7 +3246,8 @@ function resolveAutoTrack(input) {
2572
3246
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
2573
3247
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
2574
3248
  clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2575
- webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
3249
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
3250
+ errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
2576
3251
  };
2577
3252
  }
2578
3253
  function installUnloadFlush(onUnload) {