1import { getCookie } from "./cookies.js"; 2 3const DIAGNOSTICS_ENDPOINT = '/diagnostics/errors'; 4const MAX_TEXT_LENGTH = 10000; 5const MAX_PATH_LENGTH = 255; 6 7function isAbortError(error) { 8 return error?.name === 'AbortError'; 9} 10 11function toAbsoluteUrl(input) { 12 if (typeof window === 'undefined') { 13 return null; 14 } 15 16 const base = window.location.origin; 17 18 if (typeof input === 'string') { 19 return new URL(input, base); 20 } 21 22 if (input instanceof URL) { 23 return new URL(input.toString(), base); 24 } 25 26 if (typeof Request !== 'undefined' && input instanceof Request) { 27 return new URL(input.url, base); 28 } 29 30 return null; 31} 32 33function isDiagnosticsRequest(url) { 34 if (!url) return false; 35 return ( 36 url.pathname === DIAGNOSTICS_ENDPOINT 37 || url.pathname === '/v2/diagnostics/errors' 38 ); 39} 40 41function getDiagnosticsUrl() { 42 if (typeof window === 'undefined') { 43 return null; 44 } 45 46 const configBase = (window.__MV_DIAGNOSTICS_API_BASE || '').trim(); 47 if (configBase) { 48 return `${configBase}${DIAGNOSTICS_ENDPOINT}`; 49 } 50 51 const metaBase = document.querySelector('meta[name="mv-api-base"]')?.getAttribute('content')?.trim() || ''; 52 if (metaBase) { 53 return `${metaBase.replace(/\/+$/, '')}${DIAGNOSTICS_ENDPOINT}`; 54 } 55 56 const apiDomain = (getCookie('api_domain') || '').trim(); 57 if (apiDomain) { 58 return `${window.location.protocol}//${apiDomain}${DIAGNOSTICS_ENDPOINT}`; 59 } 60 61 return null; 62} 63 64function sanitizeText(value, maxLength) { 65 if (!value) return undefined; 66 const normalized = String(value).trim(); 67 if (!normalized) return undefined; 68 return normalized.slice(0, maxLength); 69} 70 71function buildMethod(input, init) { 72 if (init?.method) { 73 return String(init.method).toUpperCase(); 74 } 75 76 if (typeof Request !== 'undefined' && input instanceof Request && input.method) { 77 return String(input.method).toUpperCase(); 78 } 79 80 return 'GET'; 81} 82 83function buildMessage(error, response, method, path) { 84 const explicitMessage = sanitizeText(error?.message, MAX_TEXT_LENGTH); 85 if (explicitMessage && !/^HTTP\s+\d{3}$/i.test(explicitMessage)) { 86 return explicitMessage; 87 } 88 89 if (response) { 90 return `${method} ${path} failed with status ${response.status}`; 91 } 92 93 const fallback = explicitMessage || 'Request failed'; 94 return sanitizeText(fallback, MAX_TEXT_LENGTH) || 'Request failed'; 95} 96 97function buildClientContext() { 98 if (typeof window === 'undefined' || typeof navigator === 'undefined') { 99 return {}; 100 } 101 102 return { 103 app_version: window.APP_VERSION || undefined, 104 environment: window.APP_ENV || undefined, 105 locale: navigator.language || undefined, 106 user_agent: navigator.userAgent || undefined, 107 page_url: window.location.href, 108 referrer: document?.referrer || undefined, 109 }; 110} 111 112function buildFingerprint(method, path, response) { 113 const status = response?.status || 'network'; 114 const shortPath = sanitizeText(path, 120) || 'unknown'; 115 return sanitizeText(`frontend-request-${method}-${status}-${shortPath}`, 191); 116} 117 118function getValidStatusCode(response) { 119 const status = response?.status; 120 return Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined; 121} 122 123function isIgnorableResponse(response) { 124 if (!response) return false; 125 return ( 126 response.status === 0 127 || response.type === 'opaque' 128 || response.type === 'opaqueredirect' 129 ); 130} 131 132function buildOccurredAtRfc3339() { 133 return new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00'); 134} 135 136function parseProblemIds(bodyText) { 137 if (!bodyText) { 138 return { requestId: undefined, correlationId: undefined }; 139 } 140 141 try { 142 const parsed = JSON.parse(bodyText); 143 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { 144 return { requestId: undefined, correlationId: undefined }; 145 } 146 147 const requestId = sanitizeText(parsed.request_id || parsed.correlation_id, 191); 148 const correlationId = sanitizeText(parsed.correlation_id || parsed.request_id, 191); 149 150 return { requestId, correlationId }; 151 } catch (_parseError) {
152 return { requestId: undefined, correlationId: undefined }; 153 } 154} 155 156function sendViaBeacon(url, payload) { 157 if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') { 158 return false; 159 } 160 161 try { 162 const body = new Blob([JSON.stringify(payload)], { type: 'application/json' }); 163 return navigator.sendBeacon(url, body); 164 } catch (_error) { 165 return false; 166 } 167} 168 169async function sendPublicDiagnostics(url, payload) { 170 if (!url) { 171 return false; 172 } 173 174 if (sendViaBeacon(url, payload)) { 175 return true; 176 } 177 178 await window.fetch(url, { 179 method: 'POST', 180 headers: { 181 'Content-Type': 'application/json', 182 }, 183 body: JSON.stringify(payload), 184 keepalive: true, 185 __mvSkipDiagnosticsPatch: true, 186 }); 187 188 return true; 189} 190 191export async function sendDiagnosticsError({ 192 error, 193 input, 194 init, 195 response, 196}) { 197 if (typeof window === 'undefined') { 198 return; 199 } 200 201 if (isAbortError(error)) { 202 return; 203 } 204 205 const url = toAbsoluteUrl(input); 206 if (isDiagnosticsRequest(url) || isIgnorableResponse(response)) { 207 return; 208 } 209 210 const method = buildMethod(input, init); 211 const pathWithQuery = url ? `${url.pathname}${url.search}` : undefined; 212 const httpPath = sanitizeText(pathWithQuery, MAX_PATH_LENGTH); 213 let effectiveError = error; 214 let backendErrorBody; 215 216 if (response) { 217 try { 218 backendErrorBody = sanitizeText(await response.clone().text(), MAX_TEXT_LENGTH); 219 if (backendErrorBody) { 220 effectiveError = new Error(backendErrorBody); 221 } 222 } catch (_bodyReadError) { 223 // Ignore body parsing issues, keep fallback message. 224 } 225 } 226 227 const message = buildMessage(effectiveError, response, method, httpPath || 'unknown'); 228 const problemIds = parseProblemIds(backendErrorBody); 229 const requestId = sanitizeText(response?.headers?.get?.('x-request-id') || problemIds.requestId, 191); 230 const correlationId = sanitizeText(problemIds.correlationId || requestId, 191); 231 232 const payload = { 233 message, 234 severity: 'error', 235 type: sanitizeText(effectiveError?.name || (response ? 'HttpRequestError' : undefined), 191), 236 stack_trace: sanitizeText(effectiveError?.stack, 65000), 237 occurred_at: buildOccurredAtRfc3339(), 238 http_path: httpPath, 239 http_method: sanitizeText(method, 16), 240 status_code: getValidStatusCode(response), 241 request_id: sanitizeText(requestId, 191), 242 correlation_id: sanitizeText(correlationId, 191), 243 fingerprint: buildFingerprint(method, url?.pathname || 'unknown', response), 244 client_context: buildClientContext(), 245 extra: { 246 request_url: sanitizeText(url?.toString(), 1000), 247 request_origin: sanitizeText(url?.origin, 255), 248 status_text: sanitizeText(response?.statusText, 255), 249 backend_error_body: backendErrorBody, 250 }, 251 }; 252 253 try { 254 const diagnosticsUrl = getDiagnosticsUrl(); 255 await sendPublicDiagnostics(diagnosticsUrl, payload); 256 } catch (_reportingError) { 257 // Silent fallback: diagnostics reporting must never break UI flows. 258 } 259} 260 261export async function fetchWithDiagnostics(input, init = {}, options = {}) { 262 const { reportHttpErrors = true } = options; 263 264 const patchedInit = { 265 ...init, 266 __mvSkipDiagnosticsPatch: true, 267 }; 268 269 let response; 270 try { 271 response = await fetch(input, patchedInit); 272 } catch (error) { 273 await sendDiagnosticsError({ error, input, init: patchedInit }); 274 throw error; 275 } 276 277 if (reportHttpErrors && !response.ok && !isIgnorableResponse(response)) { 278 await sendDiagnosticsError({ 279 error: new Error(`HTTP ${response.status}`), 280 input, 281 init: patchedInit, 282 response, 283 }); 284 } 285 286 return response; 287}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.