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