feat(observability): 引入错误链路 error_flow 元数据并区分上游/客户端错误

- 网关在本地 failover 时构建 error_flow 元数据(分类/决策/传播策略),写入 report_context
- scheduler-core 解析并透传 error_flow 至候选 extra_data
- admin usage 详情拆分 request/upstream/client/failure_summary 错误域,敏感上游错误标记为 suppressed
- 前端 RequestDetailDrawer 拆出"返回客户端"与"上游响应"双错误卡片
- HorizontalRequestTimeline 节点详情展示真实请求错误及 error_flow 标签
This commit is contained in:
fawney19
2026-04-25 16:58:17 +08:00
parent bc97e383d3
commit 00744c0ce5
9 changed files with 997 additions and 44 deletions

View File

@@ -115,6 +115,33 @@ export interface VideoBilling {
status?: string // 计费状态
}
export interface RequestErrorDomain {
source?: string | null
status_code?: number | null
type?: string | null
message?: string | null
code?: string | number | null
content_type?: string | null
body?: unknown
category?: string | null
}
export interface RequestErrorDomains {
request_error?: RequestErrorDomain | null
upstream_error?: RequestErrorDomain | null
client_error?: RequestErrorDomain | null
failure_summary?: RequestErrorDomain | null
}
export interface RequestErrorFlow {
source?: string | null
status_code?: number | null
propagation?: string | null
client_response_source?: string | null
safe_to_expose_upstream?: boolean | null
summary_source?: string | null
}
export interface RequestDetail {
id: string // UUID
request_id: string
@@ -173,6 +200,12 @@ export interface RequestDetail {
status_code: number
status?: string // pending, streaming, completed, failed, cancelled
error_message?: string
request_error?: RequestErrorDomain | null
upstream_error?: RequestErrorDomain | null
client_error?: RequestErrorDomain | null
failure_summary?: RequestErrorDomain | null
errors?: RequestErrorDomains | null
error_flow?: RequestErrorFlow | null
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>

View File

@@ -439,16 +439,32 @@
<span class="reason-value">{{ currentAttemptSkipReasonDisplay }}</span>
</div>
<!-- 错误信息 -->
<!-- 真实请求错误节点级调试原因和对客户端返回的摘要分开 -->
<div
v-if="currentAttempt.status === 'failed' && (currentAttempt.error_message || currentAttempt.error_type)"
v-if="currentAttempt.status === 'failed' && currentAttemptRequestError"
class="error-block"
>
<div class="error-type">
{{ currentAttempt.error_type || '错误' }}
真实请求错误
</div>
<div class="error-msg">
{{ currentAttempt.error_message || '未知错误' }}
{{ currentAttemptRequestError.message }}
</div>
<div
v-if="currentAttemptRequestError.meta.length > 0"
class="error-flow-meta"
>
<span
v-for="item in currentAttemptRequestError.meta"
:key="item"
class="error-flow-chip"
>{{ item }}</span>
</div>
<div
v-if="currentAttemptRequestError.safetyHint"
class="error-flow-safety"
>
{{ currentAttemptRequestError.safetyHint }}
</div>
</div>
@@ -551,6 +567,17 @@ interface UsageData {
}
}
interface AttemptErrorFlow {
source?: string
statusCode?: number
classification?: string
decision?: string
retryable?: boolean
safeToExpose?: boolean
propagation?: string
message?: string
}
const props = defineProps<{
requestId?: string | null
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
@@ -569,6 +596,7 @@ const props = defineProps<{
const emit = defineEmits<{
selectAttempt: [attempt: CandidateRecord | null]
traceState: [state: { loaded: boolean, hasTrace: boolean }]
}>()
// 用量数据(从 props 获取)
@@ -626,6 +654,19 @@ const trace = computed(() => props.traceData ?? internalTrace.value)
const selectedGroupIndex = ref(0)
const selectedAttemptIndex = ref(0)
const hoveredGroupIndex = ref<number | null>(null)
const traceLoadStarted = ref(false)
watch(
[trace, loading],
([value, isLoading]) => {
const waitingForInternalTrace = Boolean(props.requestId && !props.traceData && !traceLoadStarted.value && !value)
emit('traceState', {
loaded: !isLoading && !waitingForInternalTrace,
hasTrace: Boolean(value?.candidates?.length),
})
},
{ immediate: true },
)
// 格式化延迟(自动调整单位)
const formatLatency = (ms: number | undefined | null): string => {
@@ -1022,6 +1063,80 @@ const extractObject = (value: unknown): Record<string, unknown> | null => {
return value as Record<string, unknown>
}
const readStringField = (obj: Record<string, unknown>, key: string): string | undefined => {
const value = obj[key]
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
const readNumberField = (obj: Record<string, unknown>, key: string): number | undefined => {
const value = obj[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
const readBooleanField = (obj: Record<string, unknown>, key: string): boolean | undefined => {
const value = obj[key]
return typeof value === 'boolean' ? value : undefined
}
const normalizeAttemptErrorFlow = (value: unknown): AttemptErrorFlow | null => {
const raw = extractObject(value)
if (!raw) return null
const flow: AttemptErrorFlow = {
source: readStringField(raw, 'source'),
statusCode: readNumberField(raw, 'status_code') ?? readNumberField(raw, 'statusCode'),
classification: readStringField(raw, 'classification'),
decision: readStringField(raw, 'decision'),
retryable: readBooleanField(raw, 'retryable'),
safeToExpose: readBooleanField(raw, 'safe_to_expose') ?? readBooleanField(raw, 'safeToExpose'),
propagation: readStringField(raw, 'propagation'),
message: readStringField(raw, 'message'),
}
return Object.values(flow).some(value => value !== undefined) ? flow : null
}
const labelFromMap = (value: string | undefined, labels: Record<string, string>): string | undefined => {
if (!value) return undefined
return labels[value] || value
}
const formatErrorFlowSource = (value?: string): string | undefined => labelFromMap(value, {
upstream_response: '上游响应',
request_validation: '请求校验',
gateway: '网关处理',
transport: '传输层',
scheduler: '调度层',
})
const formatErrorFlowDecision = (value?: string): string | undefined => labelFromMap(value, {
retry_next_candidate: '重试下一个候选',
stop_local_failover: '停止本地转移',
use_default: '默认处理',
return_to_client: '返回客户端',
})
const formatErrorFlowPropagation = (value?: string): string | undefined => labelFromMap(value, {
suppressed: '已抑制',
converted: '已转换',
passthrough: '直接透传',
local: '本地生成',
captured: '仅采集',
})
const formatErrorFlowClassification = (value?: string): string | undefined => labelFromMap(value, {
retryable: '可重试',
terminal: '终止',
provider_auth: '上游认证',
provider_quota: '上游额度',
invalid_request: '请求无效',
})
const extractStringList = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
@@ -1256,6 +1371,45 @@ const currentAttemptSkipReasonDisplay = computed(() => {
return detailedReason || attempt.skip_reason
})
const currentAttemptRequestError = computed<{
message: string
meta: string[]
safetyHint: string
} | null>(() => {
const attempt = currentAttempt.value
if (!attempt || attempt.status !== 'failed') return null
const extra = extractObject(attempt.extra_data)
const flow = normalizeAttemptErrorFlow(extra?.error_flow)
const fallbackMessage = typeof attempt.error_message === 'string' && attempt.error_message.trim()
? attempt.error_message.trim()
: ''
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
? attempt.error_type.trim()
: ''
const message = flow?.message || fallbackMessage
if (!message && !fallbackType && !flow) return null
const meta = [
flow?.statusCode != null ? `HTTP ${flow.statusCode}` : (attempt.status_code ? `HTTP ${attempt.status_code}` : ''),
formatErrorFlowSource(flow?.source),
formatErrorFlowClassification(flow?.classification) || fallbackType,
formatErrorFlowDecision(flow?.decision),
formatErrorFlowPropagation(flow?.propagation),
flow?.retryable != null ? (flow.retryable ? '会继续重试' : '不再重试') : '',
].filter((item): item is string => Boolean(item))
const safetyHint = flow?.safeToExpose === false
? '该错误被标记为敏感上游错误:仅在链路节点展示,不应完整返回给客户端。'
: ''
return {
message: message || fallbackType || '未知错误',
meta,
safetyHint,
}
})
// 计算当前尝试启用的能力标签(请求需要的能力)
const activeCapabilities = computed(() => {
if (!currentAttempt.value?.required_capabilities) return []
@@ -1390,6 +1544,7 @@ const loadTrace = async (silent = false) => {
if (!props.requestId || props.traceData) return
isSilentRefresh.value = silent
traceLoadStarted.value = true
if (!silent) {
loading.value = true
@@ -1481,6 +1636,7 @@ watch(
() => {
selectedGroupIndex.value = 0
selectedAttemptIndex.value = 0
traceLoadStarted.value = false
if (props.traceData) {
internalTrace.value = null
@@ -2371,6 +2527,38 @@ const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string =
word-break: break-word;
}
.error-flow-meta {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.625rem;
}
.error-flow-chip {
padding: 0.125rem 0.45rem;
border-radius: 999px;
background: #ef444414;
border: 1px solid #ef44442e;
color: #991b1b;
font-size: 0.72rem;
line-height: 1.35;
}
.error-flow-safety {
margin-top: 0.625rem;
color: #991b1b;
font-size: 0.78rem;
line-height: 1.5;
}
.dark .error-flow-chip {
color: #fecaca;
}
.dark .error-flow-safety {
color: #fecaca;
}
/* 额外信息 */
.extra-block {
margin-top: 1rem;

View File

@@ -414,25 +414,58 @@
:request-status="detail.status"
:request-api-format="detail.api_format || null"
:request-metadata="traceRequestMetadata"
@trace-state="handleTraceState"
/>
</div>
<!-- 响应客户端错误卡片 -->
<Card
v-if="detail.error_message"
class="border-red-200 dark:border-red-800"
<!-- 错误域卡片保持上游响应客户端响应两个边界可对照 -->
<div
v-if="hasVisibleErrorCards"
class="space-y-3"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
响应客户端错误
</h4>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<p class="text-sm text-red-800 dark:text-red-300">
{{ detail.error_message }}
</p>
</div>
<div
class="grid gap-3"
:class="visibleErrorCardCount > 1 ? 'lg:grid-cols-2' : 'grid-cols-1'"
>
<Card
v-if="displayClientErrorMessage"
class="border-amber-200 dark:border-amber-800"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-amber-700 dark:text-amber-300 mb-2">
返回客户端错误
</h4>
<div class="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-3 space-y-1">
<p class="text-sm text-amber-900 dark:text-amber-200">
{{ displayClientErrorMessage }}
</p>
</div>
</div>
</Card>
<Card
v-if="normalizedUpstreamError"
class="border-orange-200 dark:border-orange-800"
>
<div class="p-4">
<h4 class="text-sm font-semibold text-orange-700 dark:text-orange-300 mb-2">
上游响应错误
</h4>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 space-y-1">
<p class="text-sm text-orange-900 dark:text-orange-200">
{{ normalizedUpstreamError.message }}
</p>
<p
v-if="formatErrorDomainMeta(normalizedUpstreamError)"
class="text-xs text-orange-800/70 dark:text-orange-200/70 font-mono"
>
{{ formatErrorDomainMeta(normalizedUpstreamError) }}
</p>
</div>
</div>
</Card>
</div>
</Card>
</div>
<!-- Tabs 区域 -->
<Card>
@@ -676,7 +709,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatShortRequestId } from '@/utils/format'
import { log } from '@/utils/logger'
@@ -717,6 +750,8 @@ const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
const timelineRef = ref<InstanceType<typeof HorizontalRequestTimeline> | null>(null)
const timelineLoaded = ref(false)
const timelineHasTrace = ref(false)
const activeTab = ref('request-body')
const copiedStates = ref<Record<string, boolean>>({})
const viewMode = ref<'compare' | 'formatted' | 'raw'>('formatted')
@@ -749,11 +784,64 @@ type PricingTierLike = {
type JsonRecord = Record<string, unknown>
type NormalizedErrorDomain = {
source?: string | null
status_code?: number | null
type?: string | null
message: string
code?: string | number | null
category?: string | null
}
function asRecord(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as JsonRecord
}
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
if (!domain || typeof domain !== 'object') return null
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
if (!message) return null
return {
source: domain.source ?? null,
status_code: domain.status_code ?? null,
type: domain.type ?? null,
message,
code: domain.code ?? null,
category: domain.category ?? null,
}
}
function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
const parts: string[] = []
if (domain.status_code != null) parts.push(`HTTP ${domain.status_code}`)
if (domain.type) parts.push(domain.type)
if (domain.source) parts.push(`source=${domain.source}`)
return parts.join(' · ')
}
function simplifyClientErrorMessage(message: string): string {
let simplified = message.trim()
if (!simplified) return ''
simplified = simplified
.replace(/[(]\s*原因代码\s*[:][^)]*[)]/gi, '')
.replace(/\s+/g, ' ')
.trim()
const advisoryIndex = simplified.search(/[。.!?]\s*(请检查|请确认|原因代码|Reason|Code)/i)
if (advisoryIndex > 0) {
simplified = simplified.slice(0, advisoryIndex)
}
return simplified.replace(/[。.!?;,:\s]+$/u, '').trim()
}
function handleTraceState(state: { loaded: boolean, hasTrace: boolean }) {
timelineLoaded.value = state.loaded
timelineHasTrace.value = state.hasTrace
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
@@ -871,6 +959,27 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
return Object.keys(merged).length > 0 ? merged : null
})
const normalizedClientError = computed(() =>
normalizeErrorDomain(detail.value?.errors?.client_error ?? detail.value?.client_error),
)
const normalizedUpstreamError = computed(() =>
normalizeErrorDomain(detail.value?.errors?.upstream_error ?? detail.value?.upstream_error),
)
const displayClientErrorMessage = computed(() =>
normalizedClientError.value ? simplifyClientErrorMessage(normalizedClientError.value.message) : '',
)
const hasVisibleErrorCards = computed(() =>
Boolean(displayClientErrorMessage.value || normalizedUpstreamError.value),
)
const visibleErrorCardCount = computed(() =>
(displayClientErrorMessage.value ? 1 : 0)
+ (normalizedUpstreamError.value ? 1 : 0),
)
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
)
@@ -1652,6 +1761,12 @@ async function ensureBodyContentLoaded() {
has_provider_request_body: response.has_provider_request_body,
has_response_body: response.has_response_body,
has_client_response_body: response.has_client_response_body,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
}
bodiesLoadedForRequestId.value = cacheKey
} catch (err) {
@@ -1673,6 +1788,8 @@ async function loadDetail(id: string, silent = false) {
if (!silent) {
loading.value = true
historicalPricing.value = null
timelineLoaded.value = false
timelineHasTrace.value = false
showTimeline.value = false
clearTimelineMountTimer()
++bodyLoadRequestId
@@ -1696,6 +1813,12 @@ async function loadDetail(id: string, silent = false) {
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
request_error: sameRequest ? (previousDetail?.request_error ?? response.request_error) : response.request_error,
upstream_error: sameRequest ? (previousDetail?.upstream_error ?? response.upstream_error) : response.upstream_error,
client_error: sameRequest ? (previousDetail?.client_error ?? response.client_error) : response.client_error,
failure_summary: sameRequest ? (previousDetail?.failure_summary ?? response.failure_summary) : response.failure_summary,
errors: sameRequest ? (previousDetail?.errors ?? response.errors) : response.errors,
error_flow: sameRequest ? (previousDetail?.error_flow ?? response.error_flow) : response.error_flow,
}
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null