@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/CHANGELOG.md +104 -0
- package/dist/crossdeck.umd.min.js +2 -1
- package/dist/crossdeck.umd.min.js.map +1 -1
- package/dist/error-codes.json +1 -1
- package/dist/index.cjs +680 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +213 -2
- package/dist/index.d.ts +213 -2
- package/dist/index.mjs +680 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +680 -5
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +680 -5
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +680 -5
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.mjs +680 -5
- package/dist/vue.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
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) {
|