/
githubmirror
/
ydb-embedded-ui
Обзор
Документация
Войти
/
githubmirror
/
ydb-embedded-ui
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/utils/errors/extractErrorDetails.ts
736 строк
22 KB
Anton Standrik
fix: monitoring error message looks really bad (#3850)
29 апр 2026, 17:46
Не верифицирован
29 апр 2026, 17:46
dac73d5
Код
Авторство
О чём код?
import type {IssueMessage} from '../../types/api/query'; import {isIssuesArray, isResponseErrorWithIssues} from '../response'; export interface ErrorDetails { status?: number; statusText?: string; /** Brief error title for display: "{status} {statusText}" or "Network Error" */ title?: string; /** Raw error.message for diagnostics (shown when differs from title and dataMessage) */ message?: string; /** Specific error message extracted from response data (data.error, data.message, etc.) */ dataMessage?: string; traceId?: string; requestId?: string; proxyName?: string; workerName?: string; proxyTraceId?: string; proxyRequestId?: string; proxyRewrittenPath?: string; proxyTarget?: string; errorOrigin?: 'app' | 'proxy' | 'upstream'; errorStage?: string; requestUrl?: string; method?: string; errorCode?: string; grpcCode?: number; responseBody?: string; hasIssues?: boolean; issues?: IssueMessage[]; /** Where the error occurred: 'connection' or 'stream' */ errorPhase?: 'connection' | 'stream'; /** Network state at the time of error */ networkOnline?: boolean; networkEffectiveType?: string; } export const USEFUL_HEADERS = [ {header: 'traceresponse', key: 'traceId', transform: extractTraceIdFromTraceresponse}, {header: 'x-trace-id', key: 'traceId', transform: undefined}, {header: 'x-request-id', key: 'requestId', transform: undefined}, {header: 'x-proxy-name', key: 'proxyName', transform: undefined}, {header: 'x-worker-name', key: 'workerName', transform: undefined}, ] as const; const PROXY_HEADERS = [ {header: 'x-ydb-ui-proxy-trace-id', key: 'proxyTraceId'}, {header: 'x-ydb-ui-proxy-request-id', key: 'proxyRequestId'}, {header: 'x-ydb-ui-proxy-rewritten-path', key: 'proxyRewrittenPath'}, {header: 'x-ydb-ui-proxy-target', key: 'proxyTarget'}, ] as const; const ERROR_MARKER_HEADERS = [ {header: 'x-ydb-ui-error-origin', key: 'errorOrigin'}, {header: 'x-ydb-ui-error-stage', key: 'errorStage'}, ] as const; const PROXY_BODY_FIELDS = [ {key: 'proxyTraceId', sourceKeys: ['proxyTraceId', 'traceId', 'x-ydb-ui-proxy-trace-id']}, { key: 'proxyRequestId', sourceKeys: ['proxyRequestId', 'requestId', 'x-ydb-ui-proxy-request-id'], }, { key: 'proxyRewrittenPath', sourceKeys: ['proxyRewrittenPath', 'rewrittenPath', 'x-ydb-ui-proxy-rewritten-path'], }, {key: 'proxyTarget', sourceKeys: ['proxyTarget', 'target', 'x-ydb-ui-proxy-target']}, ] as const; const ERROR_MARKER_BODY_FIELDS = [ {key: 'errorOrigin', sourceKeys: ['errorOrigin']}, {key: 'errorStage', sourceKeys: ['errorStage']}, ] as const; const ERROR_MARKER_PROXY_BODY_FIELDS = [ {key: 'errorOrigin', sourceKeys: ['errorOrigin', 'origin']}, {key: 'errorStage', sourceKeys: ['errorStage', 'stage']}, ] as const; type ProxyDetailsKey = (typeof PROXY_HEADERS)[number]['key']; type ErrorMarkerKey = (typeof ERROR_MARKER_HEADERS)[number]['key']; type ExtractableResponseHeader = | (typeof USEFUL_HEADERS)[number]['header'] | (typeof PROXY_HEADERS)[number]['header'] | (typeof ERROR_MARKER_HEADERS)[number]['header']; interface ProxyBodyFieldDefinition { key: ProxyDetailsKey; sourceKeys: readonly string[]; } interface ErrorMarkerBodyFieldDefinition { key: ErrorMarkerKey; sourceKeys: readonly string[]; } const ERROR_ORIGIN_VALUES = ['app', 'proxy', 'upstream'] as const; export const EXTRACTABLE_RESPONSE_HEADERS: ExtractableResponseHeader[] = [ ...USEFUL_HEADERS.map(({header}) => header), ...PROXY_HEADERS.map(({header}) => header), ...ERROR_MARKER_HEADERS.map(({header}) => header), ]; function extractTraceIdFromTraceresponse(value: string): string { const parts = value.split('-'); return parts.length >= 2 ? parts[1] : value; } function normalizeStringValue(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmedValue = value.trim(); return trimmedValue || undefined; } function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function normalizeErrorOrigin(value: unknown): ErrorDetails['errorOrigin'] | undefined { const normalizedValue = normalizeStringValue(value); if (!normalizedValue) { return undefined; } return ERROR_ORIGIN_VALUES.find((allowedValue) => allowedValue === normalizedValue); } function hasStatus(error: Record<string, unknown>): boolean { return typeof error.status === 'number'; } function hasStatusText(error: Record<string, unknown>): boolean { return typeof error.statusText === 'string'; } function hasHeaders(error: Record<string, unknown>): boolean { return Boolean('headers' in error && error.headers && typeof error.headers === 'object'); } function hasConfig(error: Record<string, unknown>): boolean { return Boolean('config' in error && error.config && typeof error.config === 'object'); } function hasData(error: Record<string, unknown>): boolean { return 'data' in error && error.data !== undefined; } function normalizeErrorSource(error: Record<string, unknown>): Record<string, unknown> { if (!('response' in error) || !error.response || typeof error.response !== 'object') { return error; } const response = error.response as Record<string, unknown>; const normalized: Record<string, unknown> = {...error}; if ( !('message' in normalized) && Object.prototype.hasOwnProperty.call(error, 'message') && typeof error.message === 'string' ) { normalized.message = error.message; } if (!('name' in normalized) && 'name' in error && typeof error.name === 'string') { normalized.name = error.name; } if (!hasStatus(normalized) && hasStatus(response)) { normalized.status = response.status; } if (!hasStatusText(normalized) && hasStatusText(response)) { normalized.statusText = response.statusText; } if (!hasData(normalized) && hasData(response)) { normalized.data = response.data; } if (!hasHeaders(normalized) && hasHeaders(response)) { normalized.headers = response.headers; } if (!hasConfig(normalized) && hasConfig(response)) { normalized.config = response.config; } return normalized; } function extractHeaders(headers: unknown): Partial<ErrorDetails> { if (!headers || typeof headers !== 'object') { return {}; } const result: Record<string, string> = {}; for (const {header, key, transform} of USEFUL_HEADERS) { if (key in result) { continue; } if (header in headers) { const value = (headers as Record<string, unknown>)[header]; if (typeof value === 'string' && value) { const transformed = transform ? transform(value) : value; if (transformed) { result[key] = transformed; } } } } return result; } function extractProxyHeaders(headers: unknown): Partial<Pick<ErrorDetails, ProxyDetailsKey>> { if (!headers || typeof headers !== 'object') { return {}; } const headersRecord = headers as Record<string, unknown>; const result: Partial<Pick<ErrorDetails, ProxyDetailsKey>> = {}; for (const {header, key} of PROXY_HEADERS) { const value = normalizeStringValue(headersRecord[header]); if (value) { result[key] = value; } } return result; } function extractProxyBodyField( source: Record<string, unknown>, field: ProxyBodyFieldDefinition, ): string | undefined { for (const sourceKey of field.sourceKeys) { const value = normalizeStringValue(source[sourceKey]); if (value) { return value; } } return undefined; } function extractErrorMarkerBodyField( source: Record<string, unknown>, field: ErrorMarkerBodyFieldDefinition, ): string | undefined { for (const sourceKey of field.sourceKeys) { const value = normalizeStringValue(source[sourceKey]); if (value) { return value; } } return undefined; } function extractProxyBody(data: unknown): Partial<Pick<ErrorDetails, ProxyDetailsKey>> { if (!data || typeof data !== 'object' || !('proxyDiagnostics' in data)) { return {}; } const proxyDiagnostics = (data as Record<string, unknown>).proxyDiagnostics; if (!proxyDiagnostics || typeof proxyDiagnostics !== 'object') { return {}; } const proxyRecord = proxyDiagnostics as Record<string, unknown>; const result: Partial<Pick<ErrorDetails, ProxyDetailsKey>> = {}; for (const field of PROXY_BODY_FIELDS) { const value = extractProxyBodyField(proxyRecord, field); if (value) { result[field.key] = value; } } return result; } function extractProxyDiagnostics( headers: unknown, data: unknown, ): Partial<Pick<ErrorDetails, ProxyDetailsKey>> { const bodyDiagnostics = extractProxyBody(data); const headerDiagnostics = extractProxyHeaders(headers); return { ...bodyDiagnostics, ...headerDiagnostics, }; } function extractErrorMarkerHeaders(headers: unknown): Partial<Pick<ErrorDetails, ErrorMarkerKey>> { if (!headers || typeof headers !== 'object') { return {}; } const headersRecord = headers as Record<string, unknown>; const result: Partial<Pick<ErrorDetails, ErrorMarkerKey>> = {}; for (const {header, key} of ERROR_MARKER_HEADERS) { const rawValue = headersRecord[header]; if (key === 'errorOrigin') { const errorOrigin = normalizeErrorOrigin(rawValue); if (errorOrigin) { result.errorOrigin = errorOrigin; } continue; } const errorStage = normalizeStringValue(rawValue); if (errorStage) { result.errorStage = errorStage; } } return result; } function extractErrorMarkerFields( source: Record<string, unknown>, fields: readonly ErrorMarkerBodyFieldDefinition[], ): Partial<Pick<ErrorDetails, ErrorMarkerKey>> { const result: Partial<Pick<ErrorDetails, ErrorMarkerKey>> = {}; for (const field of fields) { const value = extractErrorMarkerBodyField(source, field); if (!value) { continue; } if (field.key === 'errorOrigin') { const errorOrigin = normalizeErrorOrigin(value); if (errorOrigin) { result.errorOrigin = errorOrigin; } continue; } result[field.key] = value; } return result; } function extractErrorMarkerBody(data: unknown): Partial<Pick<ErrorDetails, ErrorMarkerKey>> { if (!data || typeof data !== 'object') { return {}; } const dataRecord = data as Record<string, unknown>; const result = extractErrorMarkerFields(dataRecord, ERROR_MARKER_BODY_FIELDS); if (!('proxyDiagnostics' in dataRecord)) { return result; } const proxyDiagnostics = dataRecord.proxyDiagnostics; if (!proxyDiagnostics || typeof proxyDiagnostics !== 'object') { return result; } return { ...extractErrorMarkerFields( proxyDiagnostics as Record<string, unknown>, ERROR_MARKER_PROXY_BODY_FIELDS, ), ...result, }; } function extractErrorMarkers( headers: unknown, data: unknown, ): Partial<Pick<ErrorDetails, ErrorMarkerKey>> { const bodyMarkers = extractErrorMarkerBody(data); const headerMarkers = extractErrorMarkerHeaders(headers); return { ...bodyMarkers, ...headerMarkers, }; } function buildUrlWithParams(baseUrl: string, params: unknown): string { if (!params || typeof params !== 'object') { return baseUrl; } const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params as Record<string, unknown>)) { if (value !== undefined && value !== null) { searchParams.append(key, String(value)); } } const qs = searchParams.toString(); if (!qs) { return baseUrl; } const separator = baseUrl.includes('?') ? '&' : '?'; return `${baseUrl}${separator}${qs}`; } function extractConfig(config: unknown): {url?: string; method?: string} { if (!config || typeof config !== 'object') { return {}; } const result: {url?: string; method?: string} = {}; if ('url' in config && typeof config.url === 'string') { const params = 'params' in config ? config.params : undefined; result.url = buildUrlWithParams(config.url, params); } if ('method' in config && typeof config.method === 'string') { result.method = config.method.toUpperCase(); } return result; } const MAX_RESPONSE_BODY_LENGTH = 500; function extractResponseBody(data: unknown): string | undefined { if (data === undefined || data === null || data === '') { return undefined; } if (typeof data === 'string') { const trimmed = data.trim(); if (!trimmed) { return undefined; } if (trimmed.length > MAX_RESPONSE_BODY_LENGTH) { return trimmed.slice(0, MAX_RESPONSE_BODY_LENGTH) + '…'; } return trimmed; } if (typeof data === 'object') { try { const json = JSON.stringify(data, null, 2); if (!json || json === '{}') { return undefined; } if (json.length > MAX_RESPONSE_BODY_LENGTH) { return json.slice(0, MAX_RESPONSE_BODY_LENGTH) + '…'; } return json; } catch { return undefined; } } return undefined; } function extractGatewayErrorPayload( error: Record<string, unknown>, ): Record<string, unknown> | null { const data = error.data; const candidates = isRecord(data) ? [data, error] : [error]; for (const candidate of candidates) { const hasNumericStatus = typeof candidate.status === 'number'; const hasCode = typeof candidate.code === 'string'; const hasDetails = isRecord(candidate.details); if (hasNumericStatus && hasDetails && (hasCode || typeof candidate.message === 'string')) { return candidate; } } return null; } function pickGatewayResponseBodyPayload(payload: Record<string, unknown>): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const key of ['status', 'message', 'code', 'details']) { if (key in payload) { result[key] = payload[key]; } } return result; } /** * Short plain text responses can be used as the data message; * longer or formatted bodies are shown only in the Response disclosure. */ const MAX_DATA_MESSAGE_LENGTH = 200; function isShortPlainText(value: string): boolean { return value.length <= MAX_DATA_MESSAGE_LENGTH && !value.trimStart().startsWith('<'); } function extractMessageFromObject(data: object): string | undefined { if ('message' in data && typeof data.message === 'string') { return data.message; } if ('error' in data && typeof data.error === 'string') { return data.error; } if ( 'error' in data && data.error && typeof data.error === 'object' && 'message' in data.error && typeof data.error.message === 'string' ) { return data.error.message; } if ('details' in data && data.details && typeof data.details === 'object') { const description = normalizeStringValue( (data.details as Record<string, unknown>).description, ); if (description) { return description; } } if ('code' in data && typeof data.code === 'string') { return data.code; } return undefined; } export function extractDataMessage(data: unknown): string | undefined { if (!data) { return undefined; } if (typeof data === 'string') { const trimmed = data.trim(); return trimmed && isShortPlainText(trimmed) ? trimmed : undefined; } if (typeof data === 'object') { return extractMessageFromObject(data); } return undefined; } function formatTitle( status?: number, statusText?: string, errorCode?: string, message?: string, ): string | undefined { if (status !== undefined && statusText) { return `${status} ${statusText}`; } if (status !== undefined) { return String(status); } if (errorCode && message) { return message; } return undefined; } function extractBasicProperties(error: Record<string, unknown>): Partial<ErrorDetails> { const result: Partial<ErrorDetails> = {}; const gatewayPayload = extractGatewayErrorPayload(error); if (typeof error.status === 'number') { result.status = error.status; } else if (typeof gatewayPayload?.status === 'number') { result.status = gatewayPayload.status; } if (typeof error.statusText === 'string') { result.statusText = error.statusText; } if (typeof gatewayPayload?.code === 'string') { result.errorCode = gatewayPayload.code; } else if (typeof error.code === 'string') { result.errorCode = error.code; } if ('data' in error) { const body = extractResponseBody(error.data); if (body) { result.responseBody = body; } const dataMsg = extractDataMessage(error.data); if (dataMsg) { result.dataMessage = dataMsg; } } else if (gatewayPayload) { const body = extractResponseBody(pickGatewayResponseBodyPayload(gatewayPayload)); if (body) { result.responseBody = body; } } if (!result.dataMessage && gatewayPayload) { const gatewayDataMessage = extractMessageFromObject(gatewayPayload); if (gatewayDataMessage) { result.dataMessage = gatewayDataMessage; } } if (isRecord(gatewayPayload?.details)) { const grpcCode = gatewayPayload.details.grpcCode; if (typeof grpcCode === 'number') { result.grpcCode = grpcCode; } } const title = formatTitle( result.status, result.statusText, result.errorCode, typeof error.message === 'string' ? error.message : undefined, ); if (title) { result.title = title; } return result; } function extractIssues(error: object): Partial<ErrorDetails> { if (isResponseErrorWithIssues(error) && error.data) { return { hasIssues: true, issues: error.data.issues as IssueMessage[], }; } if ('issues' in error && isIssuesArray((error as Record<string, unknown>).issues)) { return { hasIssues: true, issues: (error as Record<string, unknown>).issues as IssueMessage[], }; } return {}; } function extractDiagnosticFields(error: object, existingFieldCount: number): Partial<ErrorDetails> { const result: Partial<ErrorDetails> = {}; const errorRecord = error as Record<string, unknown>; // Save error.message for diagnostics, only when other useful fields exist if (typeof errorRecord.message === 'string' && errorRecord.message && existingFieldCount > 0) { result.message = errorRecord.message; } // Extract errorPhase from enriched streaming errors if ('errorPhase' in error && typeof errorRecord.errorPhase === 'string') { result.errorPhase = errorRecord.errorPhase as 'connection' | 'stream'; } if ('networkOnline' in error && typeof errorRecord.networkOnline === 'boolean') { result.networkOnline = errorRecord.networkOnline; } return result; } /** * Extracts metadata details from error objects for display in error UI. * Works with HTTP errors (from AxiosWrapper response), network errors (from AxiosWrapper toJSON), * and response errors with issues (HTTP 429). * * Returns null for primitives, cancelled requests, and objects without useful metadata. */ export function extractErrorDetails(error: unknown): ErrorDetails | null { if (!error || typeof error !== 'object') { return null; } if ('isCancelled' in error && error.isCancelled) { return null; } const normalizedError = normalizeErrorSource(error as Record<string, unknown>); const details: ErrorDetails = { ...extractBasicProperties(normalizedError), }; // ErrorResponse has error.error.message at top level (not in .data) if (!details.dataMessage && 'error' in normalizedError) { const msg = extractDataMessage(normalizedError); if (msg) { details.dataMessage = msg; } } if ('headers' in normalizedError) { Object.assign(details, extractHeaders(normalizedError.headers)); } Object.assign(details, extractProxyDiagnostics(normalizedError.headers, normalizedError.data)); Object.assign(details, extractErrorMarkers(normalizedError.headers, normalizedError.data)); if ('config' in normalizedError) { const {url, method} = extractConfig(normalizedError.config); if (url) { details.requestUrl = url; } if (method) { details.method = method; } } Object.assign(details, extractIssues(normalizedError)); Object.assign(details, extractDiagnosticFields(normalizedError, Object.keys(details).length)); const hasAnyDetail = Object.keys(details).length > 0; return hasAnyDetail ? details : null; }