Files
breadcrumb-the-shire/web/js/core/app-telemetry.js
2026-03-06 00:44:52 +01:00

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