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 };