566 lines
16 KiB
JavaScript
566 lines
16 KiB
JavaScript
const DEFAULT_SAMPLE_RATE = 0.2;
|
|
const DEFAULT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
const DEFAULT_MAX_EVENTS_PER_MINUTE = 20;
|
|
const MAX_MESSAGE_LENGTH = 280;
|
|
const MAX_FINGERPRINT_LENGTH = 96;
|
|
const MAX_META_ENTRIES = 12;
|
|
const MAX_META_VALUE_LENGTH = 140;
|
|
const MAX_META_JSON_LENGTH = 1800;
|
|
|
|
const EVENT_KEY_TO_TYPE = Object.freeze({
|
|
warn_once: 'frontend.warn_once',
|
|
ajax_error: 'frontend.ajax_error',
|
|
});
|
|
|
|
const EVENT_TYPE_TO_KEY = Object.freeze({
|
|
'frontend.warn_once': 'warn_once',
|
|
'frontend.ajax_error': 'ajax_error',
|
|
});
|
|
|
|
const DEFAULT_ALLOWED_EVENT_KEYS = Object.freeze(['warn_once', 'ajax_error']);
|
|
const DEFAULT_ALLOWED_EVENT_TYPES = new Set(DEFAULT_ALLOWED_EVENT_KEYS.map((key) => EVENT_KEY_TO_TYPE[key]));
|
|
|
|
const AJAX_ERROR_TYPE = EVENT_KEY_TO_TYPE.ajax_error;
|
|
|
|
const ALLOWED_META_KEYS = new Set([
|
|
'action',
|
|
'code',
|
|
'component',
|
|
'error_code',
|
|
'feature',
|
|
'http_method',
|
|
'http_status',
|
|
'location',
|
|
'module',
|
|
'page_request_id',
|
|
'request_path',
|
|
'source',
|
|
'status_text',
|
|
]);
|
|
|
|
const seenFingerprints = new Map();
|
|
let fetchHookInstalled = false;
|
|
let clientRateWindowStartMs = 0;
|
|
let clientRateWindowCount = 0;
|
|
|
|
const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
const clampRate = (value, fallback = DEFAULT_SAMPLE_RATE) => {
|
|
const numeric = Number.parseFloat(String(value ?? ''));
|
|
if (!Number.isFinite(numeric)) {
|
|
return fallback;
|
|
}
|
|
if (numeric < 0) {return 0;}
|
|
if (numeric > 1) {return 1;}
|
|
return numeric;
|
|
};
|
|
|
|
const clampPositiveInt = (value, fallback) => {
|
|
const numeric = Number.parseInt(String(value ?? ''), 10);
|
|
if (!Number.isFinite(numeric) || numeric < 1) {
|
|
return fallback;
|
|
}
|
|
return numeric;
|
|
};
|
|
|
|
const normalizeEventType = (eventType) => {
|
|
const normalized = normalizeWhitespace(eventType).toLowerCase();
|
|
if (normalized in EVENT_KEY_TO_TYPE) {
|
|
return EVENT_KEY_TO_TYPE[normalized];
|
|
}
|
|
if (normalized in EVENT_TYPE_TO_KEY) {
|
|
return normalized;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const normalizeSeverity = (severity, eventType) => {
|
|
const normalized = normalizeWhitespace(severity).toLowerCase();
|
|
if (normalized === 'error' || normalized === 'warning' || normalized === 'info') {
|
|
return normalized;
|
|
}
|
|
return eventType === AJAX_ERROR_TYPE ? 'error' : 'warning';
|
|
};
|
|
|
|
const normalizeRoute = (value) => {
|
|
const raw = normalizeWhitespace(value);
|
|
if (raw === '') {
|
|
return normalizeWhitespace(window.location.pathname || '/') || '/';
|
|
}
|
|
try {
|
|
const url = new URL(raw, window.location.href);
|
|
if (url.origin !== window.location.origin) {
|
|
return '';
|
|
}
|
|
return normalizeWhitespace(url.pathname || '/') || '/';
|
|
} catch {
|
|
const stripped = raw.split('?')[0].split('#')[0];
|
|
return normalizeWhitespace(stripped || '/') || '/';
|
|
}
|
|
};
|
|
|
|
const normalizeMessage = (value) => {
|
|
const normalized = normalizeWhitespace(value);
|
|
if (normalized === '') {
|
|
return '';
|
|
}
|
|
return normalized.slice(0, MAX_MESSAGE_LENGTH);
|
|
};
|
|
|
|
const normalizeFingerprint = (value) => {
|
|
const normalized = normalizeWhitespace(value).toLowerCase();
|
|
if (normalized === '') {
|
|
return '';
|
|
}
|
|
const safe = normalized.replace(/[^a-z0-9:_-]/g, '');
|
|
return safe.slice(0, MAX_FINGERPRINT_LENGTH);
|
|
};
|
|
|
|
const hashString = (value) => {
|
|
let hash = 2166136261;
|
|
const source = String(value ?? '');
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
hash ^= source.charCodeAt(index);
|
|
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
}
|
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
};
|
|
|
|
const buildFingerprint = (eventType, message, route) => normalizeFingerprint(
|
|
`v1_${hashString(`${eventType}|${route}|${message}`)}`
|
|
);
|
|
|
|
const normalizeOccurredAt = (value) => {
|
|
const raw = normalizeWhitespace(value);
|
|
if (raw !== '') {
|
|
const timestamp = Date.parse(raw);
|
|
if (Number.isFinite(timestamp)) {
|
|
return new Date(timestamp).toISOString();
|
|
}
|
|
}
|
|
return new Date().toISOString();
|
|
};
|
|
|
|
const normalizeMetaValue = (value) => {
|
|
if (typeof value === 'boolean') {
|
|
return value ? '1' : '0';
|
|
}
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value) ? String(value) : '';
|
|
}
|
|
if (typeof value === 'string') {
|
|
return normalizeWhitespace(value).slice(0, MAX_META_VALUE_LENGTH);
|
|
}
|
|
if (value instanceof Error) {
|
|
return normalizeWhitespace(value.name || 'Error').slice(0, MAX_META_VALUE_LENGTH);
|
|
}
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
return normalizeWhitespace(String(value)).slice(0, MAX_META_VALUE_LENGTH);
|
|
};
|
|
|
|
const normalizeMeta = (meta) => {
|
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
return {};
|
|
}
|
|
|
|
const normalized = {};
|
|
let index = 0;
|
|
|
|
Object.entries(meta).forEach(([rawKey, rawValue]) => {
|
|
if (index >= MAX_META_ENTRIES) {
|
|
return;
|
|
}
|
|
|
|
const key = normalizeWhitespace(rawKey).toLowerCase();
|
|
if (key === '' || !ALLOWED_META_KEYS.has(key)) {
|
|
return;
|
|
}
|
|
|
|
const value = normalizeMetaValue(rawValue);
|
|
if (value === '') {
|
|
return;
|
|
}
|
|
|
|
normalized[key] = value;
|
|
index += 1;
|
|
});
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const normalizeAllowedEventKeys = (value) => {
|
|
const keys = new Set();
|
|
const raw = Array.isArray(value)
|
|
? value
|
|
: String(value ?? '')
|
|
.split(/[,\n]/g)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
raw.forEach((entry) => {
|
|
const key = String(entry).trim().toLowerCase();
|
|
if (key in EVENT_KEY_TO_TYPE) {
|
|
keys.add(key);
|
|
} else if (key in EVENT_TYPE_TO_KEY) {
|
|
keys.add(EVENT_TYPE_TO_KEY[key]);
|
|
}
|
|
});
|
|
|
|
if (keys.size === 0) {
|
|
DEFAULT_ALLOWED_EVENT_KEYS.forEach((key) => keys.add(key));
|
|
}
|
|
|
|
return keys;
|
|
};
|
|
|
|
const resolveConfig = () => {
|
|
const root = document.documentElement;
|
|
if (!root) {
|
|
return {
|
|
enabled: false,
|
|
endpoint: '',
|
|
csrfKey: '',
|
|
csrfToken: '',
|
|
sampleRate: DEFAULT_SAMPLE_RATE,
|
|
maxEventsPerMinute: DEFAULT_MAX_EVENTS_PER_MINUTE,
|
|
allowedEventTypes: DEFAULT_ALLOWED_EVENT_TYPES,
|
|
};
|
|
}
|
|
|
|
const enabledRaw = normalizeWhitespace(root.dataset.telemetryEnabled || '');
|
|
const enabled = enabledRaw !== '' && ['1', 'true', 'yes', 'on'].includes(enabledRaw.toLowerCase());
|
|
|
|
const allowedEventKeys = normalizeAllowedEventKeys(root.dataset.telemetryAllowedEvents || '');
|
|
const allowedEventTypes = new Set();
|
|
allowedEventKeys.forEach((key) => {
|
|
const eventType = EVENT_KEY_TO_TYPE[key];
|
|
if (eventType) {
|
|
allowedEventTypes.add(eventType);
|
|
}
|
|
});
|
|
|
|
return {
|
|
enabled,
|
|
endpoint: normalizeWhitespace(root.dataset.telemetryUrl || ''),
|
|
csrfKey: normalizeWhitespace(root.dataset.telemetryCsrfKey || ''),
|
|
csrfToken: normalizeWhitespace(root.dataset.telemetryCsrfToken || ''),
|
|
sampleRate: clampRate(root.dataset.telemetrySampleRate || DEFAULT_SAMPLE_RATE, DEFAULT_SAMPLE_RATE),
|
|
maxEventsPerMinute: clampPositiveInt(
|
|
root.dataset.telemetryMaxEventsPerMinute || DEFAULT_MAX_EVENTS_PER_MINUTE,
|
|
DEFAULT_MAX_EVENTS_PER_MINUTE
|
|
),
|
|
allowedEventTypes: allowedEventTypes.size > 0 ? allowedEventTypes : DEFAULT_ALLOWED_EVENT_TYPES,
|
|
};
|
|
};
|
|
|
|
const shouldSample = (rate) => {
|
|
if (rate <= 0) {return false;}
|
|
if (rate >= 1) {return true;}
|
|
return Math.random() < rate;
|
|
};
|
|
|
|
const trimSeenFingerprints = (now = Date.now()) => {
|
|
seenFingerprints.forEach((expiresAt, key) => {
|
|
if (expiresAt <= now) {
|
|
seenFingerprints.delete(key);
|
|
}
|
|
});
|
|
};
|
|
|
|
const isDuplicateFingerprint = (fingerprint, ttlMs, now = Date.now()) => {
|
|
trimSeenFingerprints(now);
|
|
const expiresAt = seenFingerprints.get(fingerprint) || 0;
|
|
if (expiresAt > now) {
|
|
return true;
|
|
}
|
|
seenFingerprints.set(fingerprint, now + ttlMs);
|
|
return false;
|
|
};
|
|
|
|
const hasClientRateBudget = (maxEventsPerMinute, nowMs = Date.now()) => {
|
|
const limit = clampPositiveInt(maxEventsPerMinute, DEFAULT_MAX_EVENTS_PER_MINUTE);
|
|
const windowMs = 60 * 1000;
|
|
if (clientRateWindowStartMs <= 0 || (nowMs - clientRateWindowStartMs) >= windowMs) {
|
|
clientRateWindowStartMs = nowMs;
|
|
clientRateWindowCount = 0;
|
|
}
|
|
|
|
if (clientRateWindowCount >= limit) {
|
|
return false;
|
|
}
|
|
|
|
clientRateWindowCount += 1;
|
|
return true;
|
|
};
|
|
|
|
const toUrlSearchParams = (payload, config) => {
|
|
const body = new URLSearchParams();
|
|
body.set('event_type', payload.eventType);
|
|
body.set('severity', payload.severity);
|
|
body.set('message', payload.message);
|
|
body.set('fingerprint', payload.fingerprint);
|
|
body.set('occurred_at', payload.occurredAt);
|
|
|
|
const metaJson = JSON.stringify(payload.meta);
|
|
if (metaJson && metaJson.length <= MAX_META_JSON_LENGTH) {
|
|
body.set('meta', metaJson);
|
|
}
|
|
|
|
body.set(config.csrfKey, config.csrfToken);
|
|
return body;
|
|
};
|
|
|
|
const sendPayload = (payload, config) => {
|
|
if (!config.endpoint || !config.csrfKey || !config.csrfToken) {
|
|
return false;
|
|
}
|
|
|
|
const body = toUrlSearchParams(payload, config);
|
|
|
|
try {
|
|
if (navigator.sendBeacon && navigator.sendBeacon(config.endpoint, body)) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// continue with fetch fallback.
|
|
}
|
|
|
|
try {
|
|
void fetch(config.endpoint, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
keepalive: true,
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'fetch',
|
|
'X-Telemetry-Request': '1',
|
|
},
|
|
body,
|
|
}).catch(() => {});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const headerValue = (headers, key) => {
|
|
if (!headers) {
|
|
return '';
|
|
}
|
|
|
|
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
|
return normalizeWhitespace(headers.get(key) || '');
|
|
}
|
|
|
|
if (Array.isArray(headers)) {
|
|
const tuple = headers.find((entry) => Array.isArray(entry) && String(entry[0] || '').toLowerCase() === key.toLowerCase());
|
|
return tuple ? normalizeWhitespace(tuple[1] || '') : '';
|
|
}
|
|
|
|
if (typeof headers === 'object') {
|
|
const direct = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
|
|
return normalizeWhitespace(direct || '');
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const fetchInputUrl = (input) => {
|
|
if (typeof input === 'string') {
|
|
return input;
|
|
}
|
|
if (input instanceof URL) {
|
|
return input.toString();
|
|
}
|
|
if (typeof Request !== 'undefined' && input instanceof Request) {
|
|
return input.url;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const fetchInputMethod = (input, init) => {
|
|
const initMethod = normalizeWhitespace(init?.method || '').toUpperCase();
|
|
if (initMethod !== '') {
|
|
return initMethod;
|
|
}
|
|
if (typeof Request !== 'undefined' && input instanceof Request) {
|
|
const requestMethod = normalizeWhitespace(input.method || '').toUpperCase();
|
|
if (requestMethod !== '') {
|
|
return requestMethod;
|
|
}
|
|
}
|
|
return 'GET';
|
|
};
|
|
|
|
const isTelemetryFetch = (input, init, telemetryPath) => {
|
|
if (telemetryPath === '') {
|
|
return false;
|
|
}
|
|
|
|
const requestHeaders = init?.headers;
|
|
const inputHeaders = typeof Request !== 'undefined' && input instanceof Request ? input.headers : null;
|
|
const marker = headerValue(requestHeaders, 'X-Telemetry-Request') || headerValue(inputHeaders, 'X-Telemetry-Request');
|
|
if (marker === '1') {
|
|
return true;
|
|
}
|
|
|
|
const requestPath = normalizeRoute(fetchInputUrl(input));
|
|
return requestPath !== '' && requestPath === telemetryPath;
|
|
};
|
|
|
|
const shouldTrackFetch = (input, init, config) => {
|
|
if (!config.enabled || !config.allowedEventTypes.has(AJAX_ERROR_TYPE)) {
|
|
return false;
|
|
}
|
|
|
|
const rawUrl = fetchInputUrl(input);
|
|
if (rawUrl === '') {
|
|
return false;
|
|
}
|
|
|
|
const path = normalizeRoute(rawUrl);
|
|
if (path === '') {
|
|
return false;
|
|
}
|
|
|
|
const telemetryPath = normalizeRoute(config.endpoint);
|
|
if (isTelemetryFetch(input, init, telemetryPath)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export const telemetry = {
|
|
capture(eventType, payload = {}, options = {}) {
|
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const normalizedEventType = normalizeEventType(eventType);
|
|
if (normalizedEventType === '') {
|
|
return false;
|
|
}
|
|
|
|
const config = resolveConfig();
|
|
if (!config.enabled || !config.allowedEventTypes.has(normalizedEventType)) {
|
|
return false;
|
|
}
|
|
|
|
const message = normalizeMessage(payload.message || '');
|
|
if (message === '') {
|
|
return false;
|
|
}
|
|
|
|
const route = normalizeRoute(payload.route || window.location.pathname || '/');
|
|
const fingerprint = normalizeFingerprint(payload.fingerprint || '')
|
|
|| buildFingerprint(normalizedEventType, message, route);
|
|
if (fingerprint === '') {
|
|
return false;
|
|
}
|
|
|
|
const dedupeTtlMs = Math.max(0, Number.parseInt(String(options.dedupeTtlMs ?? DEFAULT_DEDUPE_TTL_MS), 10) || 0);
|
|
if (!options.skipDedupe && dedupeTtlMs > 0 && isDuplicateFingerprint(fingerprint, dedupeTtlMs)) {
|
|
return false;
|
|
}
|
|
|
|
const sampleRate = clampRate(
|
|
options.sampleRate !== undefined ? options.sampleRate : config.sampleRate,
|
|
config.sampleRate
|
|
);
|
|
if (!shouldSample(sampleRate)) {
|
|
return false;
|
|
}
|
|
|
|
const maxEventsPerMinute = clampPositiveInt(
|
|
options.maxEventsPerMinute !== undefined ? options.maxEventsPerMinute : config.maxEventsPerMinute,
|
|
config.maxEventsPerMinute
|
|
);
|
|
if (!options.skipClientRateLimit && !hasClientRateBudget(maxEventsPerMinute)) {
|
|
return false;
|
|
}
|
|
|
|
const meta = normalizeMeta(payload.meta || {});
|
|
if (route !== '') {
|
|
meta.location = route;
|
|
}
|
|
const pageRequestId = normalizeWhitespace(document.documentElement?.dataset.requestId || '');
|
|
if (pageRequestId !== '') {
|
|
meta.page_request_id = pageRequestId.slice(0, 36);
|
|
}
|
|
|
|
return sendPayload({
|
|
eventType: normalizedEventType,
|
|
severity: normalizeSeverity(payload.severity, normalizedEventType),
|
|
message,
|
|
fingerprint,
|
|
meta,
|
|
occurredAt: normalizeOccurredAt(payload.occurredAt),
|
|
}, config);
|
|
},
|
|
|
|
installGlobalFetchHook() {
|
|
if (fetchHookInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = (input, init) => {
|
|
const config = resolveConfig();
|
|
if (!shouldTrackFetch(input, init, config)) {
|
|
return originalFetch(input, init);
|
|
}
|
|
|
|
const method = fetchInputMethod(input, init);
|
|
const requestPath = normalizeRoute(fetchInputUrl(input));
|
|
|
|
return originalFetch(input, init)
|
|
.then((response) => {
|
|
const shouldTrackStatus = response.status >= 500 || response.status === 429;
|
|
if (!response.ok && shouldTrackStatus) {
|
|
telemetry.capture(AJAX_ERROR_TYPE, {
|
|
severity: response.status >= 500 ? 'error' : 'warning',
|
|
message: `AJAX request failed (${response.status}) ${method} ${requestPath || '/'}`,
|
|
meta: {
|
|
source: 'fetch',
|
|
module: 'global-fetch',
|
|
http_method: method,
|
|
http_status: response.status,
|
|
status_text: response.statusText || '',
|
|
request_path: requestPath || '/',
|
|
},
|
|
});
|
|
}
|
|
return response;
|
|
})
|
|
.catch((error) => {
|
|
if (!(error instanceof Error && error.name === 'AbortError')) {
|
|
telemetry.capture(AJAX_ERROR_TYPE, {
|
|
severity: 'error',
|
|
message: `AJAX network error ${method} ${requestPath || '/'}`,
|
|
meta: {
|
|
source: 'fetch',
|
|
module: 'global-fetch',
|
|
http_method: method,
|
|
request_path: requestPath || '/',
|
|
error_code: error instanceof Error ? error.name : 'NetworkError',
|
|
},
|
|
});
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
fetchHookInstalled = true;
|
|
},
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
telemetry.installGlobalFetchHook();
|
|
}
|
|
|
|
export { DEFAULT_DEDUPE_TTL_MS };
|