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