SHiNE-server/shine-UI/js/services/client-error-reporter.js

135 lines
3.9 KiB
JavaScript

const MAX_CONTEXT_LEN = 2000;
const RECENT_WINDOW_MS = 5000;
const UI_ERROR_REPORTING_KEY = 'shine-ui-send-errors-to-server-v1';
let transport = null;
let transportDepth = 0;
const recentFingerprints = new Map();
let notifySent = null;
function nowTs() {
return Date.now();
}
function cleanString(value, maxLen = 1000) {
if (value == null) return '';
const normalized = String(value).replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, Math.max(0, maxLen - 3))}...`;
}
function stringifyContext(context) {
if (context == null) return '';
try {
const raw = JSON.stringify(context);
if (!raw) return '';
if (raw.length <= MAX_CONTEXT_LEN) return raw;
return `${raw.slice(0, MAX_CONTEXT_LEN - 3)}...`;
} catch (error) {
return cleanString(`context_json_error:${error?.message || error}`, MAX_CONTEXT_LEN);
}
}
function makeFingerprint(payload) {
return [
payload.kind,
payload.message,
payload.sourceUrl,
payload.lineNumber,
payload.columnNumber,
payload.requestOp,
].join('|');
}
function isDuplicate(fingerprint) {
const ts = nowTs();
const prev = recentFingerprints.get(fingerprint);
recentFingerprints.set(fingerprint, ts);
for (const [key, time] of recentFingerprints.entries()) {
if (ts - time > RECENT_WINDOW_MS) {
recentFingerprints.delete(key);
}
}
return prev != null && ts - prev < RECENT_WINDOW_MS;
}
function buildPayload(details = {}) {
return {
kind: cleanString(details.kind || 'client_error', 64),
message: cleanString(details.message || details.reason || 'Unknown client error', 500),
stack: cleanString(details.stack || details.error?.stack || '', 8000),
sourceUrl: cleanString(details.sourceUrl || details.fileName || '', 240),
lineNumber: Number.isFinite(details.lineNumber) ? details.lineNumber : null,
columnNumber: Number.isFinite(details.columnNumber) ? details.columnNumber : null,
route: cleanString(details.route || window.location?.hash || '', 200),
href: cleanString(details.href || window.location?.href || '', 240),
userAgent: cleanString(details.userAgent || navigator.userAgent || '', 240),
clientTs: Number.isFinite(details.clientTs) ? details.clientTs : nowTs(),
requestOp: cleanString(details.requestOp || '', 64),
requestIdRef: cleanString(details.requestIdRef || '', 128),
contextJson: stringifyContext({
title: document.title || '',
pageVisibility: document.visibilityState || '',
...details.context,
}),
};
}
export function setClientErrorTransport(fn) {
transport = typeof fn === 'function' ? fn : null;
}
export function setClientErrorSentNotifier(fn) {
notifySent = typeof fn === 'function' ? fn : null;
}
export function isClientErrorReportingEnabled() {
try {
return localStorage.getItem(UI_ERROR_REPORTING_KEY) === '1';
} catch {
return false;
}
}
export function setClientErrorReportingEnabled(enabled) {
try {
localStorage.setItem(UI_ERROR_REPORTING_KEY, enabled ? '1' : '0');
} catch {
// ignore storage errors
}
}
export async function captureClientError(details = {}) {
const payload = buildPayload(details);
if (!payload.message) return false;
const fingerprint = details.dedupeKey || makeFingerprint(payload);
if (isDuplicate(fingerprint)) return false;
console.error('[client-error]', payload.kind, payload.message, details.error || '');
if (!transport || details.skipTransport === true || transportDepth > 0 || !isClientErrorReportingEnabled()) {
return false;
}
try {
transportDepth += 1;
await transport(payload);
if (notifySent) {
try {
notifySent(payload);
} catch {
// ignore notifier errors
}
}
return true;
} catch (error) {
console.warn('client error transport failed', error);
return false;
} finally {
transportDepth = Math.max(0, transportDepth - 1);
}
}