From 8c4a217f0320c6a091894c515b6be4c7859a64db Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sat, 21 Mar 2026 23:30:13 +0800 Subject: [PATCH 01/15] feat(ops): add endpoint/model/request_type fields to error log structs + safeUpstreamURL --- backend/internal/service/ops_models.go | 6 ++++ backend/internal/service/ops_port.go | 11 ++++++++ .../internal/service/ops_upstream_context.go | 21 ++++++++++++++ ...079_ops_error_logs_add_endpoint_fields.sql | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 backend/migrations/079_ops_error_logs_add_endpoint_fields.sql diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 2ed06d90..5fefb74f 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -62,6 +62,12 @@ type OpsErrorLog struct { ClientIP *string `json:"client_ip"` RequestPath string `json:"request_path"` Stream bool `json:"stream"` + + InboundEndpoint string `json:"inbound_endpoint"` + UpstreamEndpoint string `json:"upstream_endpoint"` + RequestedModel string `json:"requested_model"` + UpstreamModel string `json:"upstream_model"` + RequestType *int16 `json:"request_type"` } type OpsErrorLogDetail struct { diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 0ce9d425..04bf91c8 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -79,6 +79,17 @@ type OpsInsertErrorLogInput struct { Model string RequestPath string Stream bool + // InboundEndpoint is the normalized client-facing API endpoint path, e.g. /v1/chat/completions. + InboundEndpoint string + // UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses. + UpstreamEndpoint string + // RequestedModel is the client-requested model name before mapping. + RequestedModel string + // UpstreamModel is the actual model sent to upstream after mapping. Empty means no mapping. + UpstreamModel string + // RequestType is the granular request type: 0=unknown, 1=sync, 2=stream, 3=ws_v2. + // Matches service.RequestType enum semantics from usage_log.go. + RequestType *int16 UserAgent string ErrorPhase string diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 9adf5896..05d444e1 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -93,6 +93,10 @@ type OpsUpstreamErrorEvent struct { UpstreamStatusCode int `json:"upstream_status_code,omitempty"` UpstreamRequestID string `json:"upstream_request_id,omitempty"` + // UpstreamURL is the actual upstream URL that was called (host + path, query/fragment stripped). + // Helps debug 404/routing errors by showing which endpoint was targeted. + UpstreamURL string `json:"upstream_url,omitempty"` + // Best-effort upstream request capture (sanitized+trimmed). // Required for retrying a specific upstream attempt. UpstreamRequestBody string `json:"upstream_request_body,omitempty"` @@ -119,6 +123,7 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { ev.UpstreamRequestBody = strings.TrimSpace(ev.UpstreamRequestBody) ev.UpstreamResponseBody = strings.TrimSpace(ev.UpstreamResponseBody) ev.Kind = strings.TrimSpace(ev.Kind) + ev.UpstreamURL = strings.TrimSpace(ev.UpstreamURL) ev.Message = strings.TrimSpace(ev.Message) ev.Detail = strings.TrimSpace(ev.Detail) if ev.Message != "" { @@ -205,3 +210,19 @@ func ParseOpsUpstreamErrors(raw string) ([]*OpsUpstreamErrorEvent, error) { } return out, nil } + +// safeUpstreamURL returns scheme + host + path from a URL, stripping query/fragment +// to avoid leaking sensitive query parameters (e.g. OAuth tokens). +func safeUpstreamURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if idx := strings.IndexByte(rawURL, '?'); idx >= 0 { + rawURL = rawURL[:idx] + } + if idx := strings.IndexByte(rawURL, '#'); idx >= 0 { + rawURL = rawURL[:idx] + } + return rawURL +} diff --git a/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql b/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql new file mode 100644 index 00000000..56f83b84 --- /dev/null +++ b/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql @@ -0,0 +1,28 @@ +-- Ops error logs: add endpoint, model mapping, and request_type fields +-- to match usage_logs observability coverage. +-- +-- All columns are nullable with no default to preserve backward compatibility +-- with existing rows. + +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '10min'; + +-- 1) Standardized endpoint paths (analogous to usage_logs.inbound_endpoint / upstream_endpoint) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS inbound_endpoint VARCHAR(256), + ADD COLUMN IF NOT EXISTS upstream_endpoint VARCHAR(256); + +-- 2) Model mapping fields (analogous to usage_logs.requested_model / upstream_model) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS requested_model VARCHAR(100), + ADD COLUMN IF NOT EXISTS upstream_model VARCHAR(100); + +-- 3) Granular request type enum (analogous to usage_logs.request_type: 0=unknown, 1=sync, 2=stream, 3=ws_v2) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS request_type SMALLINT; + +COMMENT ON COLUMN ops_error_logs.inbound_endpoint IS 'Normalized client-facing API endpoint path, e.g. /v1/chat/completions. Populated from InboundEndpointMiddleware.'; +COMMENT ON COLUMN ops_error_logs.upstream_endpoint IS 'Normalized upstream endpoint path derived from platform, e.g. /v1/responses.'; +COMMENT ON COLUMN ops_error_logs.requested_model IS 'Client-requested model name before mapping (raw from request body).'; +COMMENT ON COLUMN ops_error_logs.upstream_model IS 'Actual model sent to upstream provider after mapping. NULL means no mapping applied.'; +COMMENT ON COLUMN ops_error_logs.request_type IS 'Request type enum: 0=unknown, 1=sync, 2=stream, 3=ws_v2. Matches usage_logs.request_type semantics.'; From a2418c604081b487545f93a3fe79dd3a805c5c9a Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sat, 21 Mar 2026 23:38:00 +0800 Subject: [PATCH 02/15] feat(ops): adapt repository INSERT/SELECT + add setOpsEndpointContext in error logger middleware --- backend/internal/handler/ops_error_logger.go | 65 +++++++++++++++++++- backend/internal/repository/ops_repo.go | 51 ++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index ceb06f0e..90e90dd0 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -27,6 +27,9 @@ const ( opsRequestBodyKey = "ops_request_body" opsAccountIDKey = "ops_account_id" + opsUpstreamModelKey = "ops_upstream_model" + opsRequestTypeKey = "ops_request_type" + // 错误过滤匹配常量 — shouldSkipOpsErrorLog 和错误分类共用 opsErrContextCanceled = "context canceled" opsErrNoAvailableAccounts = "no available accounts" @@ -345,6 +348,18 @@ func setOpsRequestContext(c *gin.Context, model string, stream bool, requestBody } } +// setOpsEndpointContext stores upstream model and request type for ops error logging. +// Called by handlers after model mapping and request type determination. +func setOpsEndpointContext(c *gin.Context, upstreamModel string, requestType int16) { + if c == nil { + return + } + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + c.Set(opsUpstreamModelKey, upstreamModel) + } + c.Set(opsRequestTypeKey, requestType) +} + func attachOpsRequestBodyToEntry(c *gin.Context, entry *service.OpsInsertErrorLogInput) { if c == nil || entry == nil { return @@ -628,7 +643,30 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } return "" }(), - Stream: stream, + Stream: stream, + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, platform), + RequestedModel: modelName, + UpstreamModel: func() string { + if v, ok := c.Get(opsUpstreamModelKey); ok { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + } + return "" + }(), + RequestType: func() *int16 { + if v, ok := c.Get(opsRequestTypeKey); ok { + switch t := v.(type) { + case int16: + return &t + case int: + v16 := int16(t) + return &v16 + } + } + return nil + }(), UserAgent: c.GetHeader("User-Agent"), ErrorPhase: "upstream", @@ -756,7 +794,30 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } return "" }(), - Stream: stream, + Stream: stream, + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, platform), + RequestedModel: modelName, + UpstreamModel: func() string { + if v, ok := c.Get(opsUpstreamModelKey); ok { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + } + return "" + }(), + RequestType: func() *int16 { + if v, ok := c.Get(opsRequestTypeKey); ok { + switch t := v.(type) { + case int16: + return &t + case int: + v16 := int16(t) + return &v16 + } + } + return nil + }(), UserAgent: c.GetHeader("User-Agent"), ErrorPhase: phase, diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 02ca1a3b..5154b269 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -29,6 +29,11 @@ INSERT INTO ops_error_logs ( model, request_path, stream, + inbound_endpoint, + upstream_endpoint, + requested_model, + upstream_model, + request_type, user_agent, error_phase, error_type, @@ -57,7 +62,7 @@ INSERT INTO ops_error_logs ( retry_count, created_at ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38 + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43 )` func NewOpsRepository(db *sql.DB) service.OpsRepository { @@ -140,6 +145,11 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any { opsNullString(input.Model), opsNullString(input.RequestPath), input.Stream, + opsNullString(input.InboundEndpoint), + opsNullString(input.UpstreamEndpoint), + opsNullString(input.RequestedModel), + opsNullString(input.UpstreamModel), + opsNullInt16(input.RequestType), opsNullString(input.UserAgent), input.ErrorPhase, input.ErrorType, @@ -231,7 +241,12 @@ SELECT COALESCE(g.name, ''), CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, COALESCE(e.request_path, ''), - e.stream + e.stream, + COALESCE(e.inbound_endpoint, ''), + COALESCE(e.upstream_endpoint, ''), + COALESCE(e.requested_model, ''), + COALESCE(e.upstream_model, ''), + e.request_type FROM ops_error_logs e LEFT JOIN accounts a ON e.account_id = a.id LEFT JOIN groups g ON e.group_id = g.id @@ -263,6 +278,7 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) var resolvedBy sql.NullInt64 var resolvedByName string var resolvedRetryID sql.NullInt64 + var requestType sql.NullInt64 if err := rows.Scan( &item.ID, &item.CreatedAt, @@ -294,6 +310,11 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) &clientIP, &item.RequestPath, &item.Stream, + &item.InboundEndpoint, + &item.UpstreamEndpoint, + &item.RequestedModel, + &item.UpstreamModel, + &requestType, ); err != nil { return nil, err } @@ -334,6 +355,10 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2) item.GroupID = &v } item.GroupName = groupName + if requestType.Valid { + v := int16(requestType.Int64) + item.RequestType = &v + } out = append(out, &item) } if err := rows.Err(); err != nil { @@ -393,6 +418,11 @@ SELECT CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END, COALESCE(e.request_path, ''), e.stream, + COALESCE(e.inbound_endpoint, ''), + COALESCE(e.upstream_endpoint, ''), + COALESCE(e.requested_model, ''), + COALESCE(e.upstream_model, ''), + e.request_type, COALESCE(e.user_agent, ''), e.auth_latency_ms, e.routing_latency_ms, @@ -427,6 +457,7 @@ LIMIT 1` var responseLatency sql.NullInt64 var ttft sql.NullInt64 var requestBodyBytes sql.NullInt64 + var requestType sql.NullInt64 err := r.db.QueryRowContext(ctx, q, id).Scan( &out.ID, @@ -464,6 +495,11 @@ LIMIT 1` &clientIP, &out.RequestPath, &out.Stream, + &out.InboundEndpoint, + &out.UpstreamEndpoint, + &out.RequestedModel, + &out.UpstreamModel, + &requestType, &out.UserAgent, &authLatency, &routingLatency, @@ -540,6 +576,10 @@ LIMIT 1` v := int(requestBodyBytes.Int64) out.RequestBodyBytes = &v } + if requestType.Valid { + v := int16(requestType.Int64) + out.RequestType = &v + } // Normalize request_body to empty string when stored as JSON null. out.RequestBody = strings.TrimSpace(out.RequestBody) @@ -1479,3 +1519,10 @@ func opsNullInt(v any) any { return sql.NullInt64{} } } + +func opsNullInt16(v *int16) any { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} +} From db9021f9c15a6cfc7cedf0cc52b8a2d48a32eeaa Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sat, 21 Mar 2026 23:47:39 +0800 Subject: [PATCH 03/15] feat(ops): propagate endpoint/request-type context in handlers; add UpstreamURL to upstream error events --- backend/internal/handler/gateway_handler.go | 2 ++ backend/internal/handler/gemini_v1beta_handler.go | 1 + backend/internal/handler/openai_chat_completions.go | 1 + backend/internal/handler/openai_gateway_handler.go | 3 +++ backend/internal/handler/sora_gateway_handler.go | 1 + .../internal/service/antigravity_gateway_service.go | 3 +++ backend/internal/service/gateway_service.go | 12 ++++++++++++ 7 files changed, 23 insertions(+) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index e1b1b9a8..b9285c04 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -178,6 +178,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled())) setOpsRequestContext(c, reqModel, reqStream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) // 验证 model 必填 if reqModel == "" { @@ -1396,6 +1397,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { } setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false))) // 获取订阅信息(可能为nil) subscription, _ := middleware2.GetSubscriptionFromContext(c) diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index fb231898..5dc03b6d 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -182,6 +182,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } setOpsRequestContext(c, modelName, stream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false))) // Get subscription (may be nil) subscription, _ := middleware.GetSubscriptionFromContext(c) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index dd158d8b..0c94aa21 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -77,6 +77,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) setOpsRequestContext(c, reqModel, reqStream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index b7f18d21..3ce6e5d6 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -183,6 +183,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } setOpsRequestContext(c, reqModel, reqStream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) // 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。 if !h.validateFunctionCallOutputRequest(c, body, reqLog) { @@ -545,6 +546,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) setOpsRequestContext(c, reqModel, reqStream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) // 绑定错误透传服务,允许 service 层在非 failover 错误场景复用规则。 if h.errorPassthroughService != nil { @@ -1096,6 +1098,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.String("previous_response_id_kind", previousResponseIDKind), ) setOpsRequestContext(c, reqModel, true, firstMessage) + setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2)) var currentUserRelease func() var currentAccountRelease func() diff --git a/backend/internal/handler/sora_gateway_handler.go b/backend/internal/handler/sora_gateway_handler.go index cc1b1c0b..5e505409 100644 --- a/backend/internal/handler/sora_gateway_handler.go +++ b/backend/internal/handler/sora_gateway_handler.go @@ -159,6 +159,7 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) { } setOpsRequestContext(c, reqModel, clientStream, body) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(clientStream, false))) platform := "" if forced, ok := middleware2.GetForcePlatformFromContext(c); ok { diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 6ee8280c..aa5d948c 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -643,6 +643,7 @@ urlFallbackLoop: AccountID: p.account.ID, AccountName: p.account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "request_error", Message: safeErr, }) @@ -720,6 +721,7 @@ urlFallbackLoop: AccountName: p.account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "retry", Message: upstreamMsg, Detail: getUpstreamDetail(respBody), @@ -754,6 +756,7 @@ urlFallbackLoop: AccountName: p.account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "retry", Message: upstreamMsg, Detail: getUpstreamDetail(respBody), diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 72cef2ac..781e6a01 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4148,6 +4148,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "request_error", Message: safeErr, }) @@ -4174,6 +4175,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "signature_error", Message: extractUpstreamErrorMessage(respBody), Detail: func() string { @@ -4228,6 +4230,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountName: account.Name, UpstreamStatusCode: retryResp.StatusCode, UpstreamRequestID: retryResp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(retryReq.URL.String()), Kind: "signature_retry_thinking", Message: extractUpstreamErrorMessage(retryRespBody), Detail: func() string { @@ -4258,6 +4261,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(retryReq2.URL.String()), Kind: "signature_retry_tools_request_error", Message: sanitizeUpstreamErrorMessage(retryErr2.Error()), }) @@ -4297,6 +4301,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "budget_constraint_error", Message: errMsg, Detail: func() string { @@ -4358,6 +4363,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "retry", Message: extractUpstreamErrorMessage(respBody), Detail: func() string { @@ -4628,6 +4634,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Passthrough: true, Kind: "request_error", Message: safeErr, @@ -4667,6 +4674,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Passthrough: true, Kind: "retry", Message: extractUpstreamErrorMessage(respBody), @@ -5344,6 +5352,7 @@ func (s *GatewayService) executeBedrockUpstream( AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "request_error", Message: safeErr, }) @@ -5380,6 +5389,7 @@ func (s *GatewayService) executeBedrockUpstream( AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Kind: "retry", Message: extractUpstreamErrorMessage(respBody), Detail: func() string { @@ -8064,6 +8074,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex AccountID: account.ID, AccountName: account.Name, UpstreamStatusCode: 0, + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Passthrough: true, Kind: "request_error", Message: sanitizeUpstreamErrorMessage(err.Error()), @@ -8119,6 +8130,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex AccountName: account.Name, UpstreamStatusCode: resp.StatusCode, UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()), Passthrough: true, Kind: "http_error", Message: upstreamMsg, From 7cd38248633f72660822183f0d87188a34b3a8af Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sat, 21 Mar 2026 23:49:50 +0800 Subject: [PATCH 04/15] test(ops): add tests for setOpsEndpointContext and safeUpstreamURL --- .../internal/handler/ops_error_logger_test.go | 39 +++++++++++++++++++ .../service/ops_upstream_context_test.go | 21 ++++++++++ 2 files changed, 60 insertions(+) diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 679dd4ce..f78d25a1 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -274,3 +274,42 @@ func TestNormalizeOpsErrorType(t *testing.T) { }) } } + +func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + setOpsEndpointContext(c, "claude-3-5-sonnet-20241022", int16(2)) // stream + + v, ok := c.Get(opsUpstreamModelKey) + require.True(t, ok) + require.Equal(t, "claude-3-5-sonnet-20241022", v.(string)) + + rt, ok := c.Get(opsRequestTypeKey) + require.True(t, ok) + require.Equal(t, int16(2), rt.(int16)) +} + +func TestSetOpsEndpointContext_EmptyModelNotStored(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + setOpsEndpointContext(c, "", int16(1)) + + _, ok := c.Get(opsUpstreamModelKey) + require.False(t, ok, "empty upstream model should not be stored") + + rt, ok := c.Get(opsRequestTypeKey) + require.True(t, ok) + require.Equal(t, int16(1), rt.(int16)) +} + +func TestSetOpsEndpointContext_NilContext(t *testing.T) { + require.NotPanics(t, func() { + setOpsEndpointContext(nil, "model", int16(1)) + }) +} diff --git a/backend/internal/service/ops_upstream_context_test.go b/backend/internal/service/ops_upstream_context_test.go index 50ceaa0e..fa6d1085 100644 --- a/backend/internal/service/ops_upstream_context_test.go +++ b/backend/internal/service/ops_upstream_context_test.go @@ -8,6 +8,27 @@ import ( "github.com/stretchr/testify/require" ) +func TestSafeUpstreamURL(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"strips query", "https://api.anthropic.com/v1/messages?beta=true", "https://api.anthropic.com/v1/messages"}, + {"strips fragment", "https://api.openai.com/v1/responses#frag", "https://api.openai.com/v1/responses"}, + {"strips both", "https://host/path?token=secret#x", "https://host/path"}, + {"no query or fragment", "https://host/path", "https://host/path"}, + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"query before fragment", "https://h/p?a=1#f", "https://h/p"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, safeUpstreamURL(tt.input)) + }) + } +} + func TestAppendOpsUpstreamError_UsesRequestBodyBytesFromContext(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() From bd8eadb75b92601e4aabe4353b0abd9600af7836 Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sun, 22 Mar 2026 19:56:29 +0800 Subject: [PATCH 05/15] feat(ops): enhance error observability with additional context fields and UI updates --- frontend/src/api/admin/ops.ts | 7 ++ frontend/src/i18n/locales/en.ts | 17 ++++- frontend/src/i18n/locales/zh.ts | 17 ++++- .../ops/components/OpsErrorDetailModal.vue | 39 ++++++++++- .../admin/ops/components/OpsErrorLogTable.vue | 64 +++++++++++++++++-- 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 64f6a6d0..ac58eff4 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -969,6 +969,13 @@ export interface OpsErrorLog { client_ip?: string | null request_path?: string stream?: boolean + + // Error observability context (endpoint + model mapping) + inbound_endpoint?: string + upstream_endpoint?: string + requested_model?: string + upstream_model?: string + request_type?: number | null } export interface OpsErrorDetail extends OpsErrorLog { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e5a370c8..b5bba9a2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3486,7 +3486,12 @@ export default { typeRequest: 'Request', typeAuth: 'Auth', typeRouting: 'Routing', - typeInternal: 'Internal' + typeInternal: 'Internal', + endpoint: 'Endpoint', + requestType: 'Type', + requestTypeSync: 'Sync', + requestTypeStream: 'Stream', + requestTypeWs: 'WS' }, // Error Details Modal errorDetails: { @@ -3572,6 +3577,16 @@ export default { latency: 'Request Duration', businessLimited: 'Business Limited', requestPath: 'Request Path', + inboundEndpoint: 'Inbound Endpoint', + upstreamEndpoint: 'Upstream Endpoint', + requestedModel: 'Requested Model', + upstreamModel: 'Upstream Model', + requestType: 'Request Type', + requestTypeUnknown: 'Unknown', + requestTypeSync: 'Sync', + requestTypeStream: 'Stream', + requestTypeWs: 'WebSocket', + modelMapping: 'Model Mapping', timings: 'Timings', auth: 'Auth', routing: 'Routing', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ac6632be..05c69426 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3651,7 +3651,12 @@ export default { typeRequest: '请求', typeAuth: '认证', typeRouting: '路由', - typeInternal: '内部' + typeInternal: '内部', + endpoint: '端点', + requestType: '类型', + requestTypeSync: '同步', + requestTypeStream: '流式', + requestTypeWs: 'WS' }, // Error Details Modal errorDetails: { @@ -3737,6 +3742,16 @@ export default { latency: '请求时长', businessLimited: '业务限制', requestPath: '请求路径', + inboundEndpoint: '入站端点', + upstreamEndpoint: '上游端点', + requestedModel: '请求模型', + upstreamModel: '上游模型', + requestType: '请求类型', + requestTypeUnknown: '未知', + requestTypeSync: '同步', + requestTypeStream: '流式', + requestTypeWs: 'WebSocket', + modelMapping: '模型映射', timings: '时序信息', auth: '认证', routing: '路由', diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index a7edff96..4bcd0c41 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -59,7 +59,28 @@
{{ t('admin.ops.errorDetail.model') }}
- {{ detail.model || '—' }} + + +
+
+ +
+
{{ t('admin.ops.errorDetail.inboundEndpoint') }}
+
+ {{ detail.inbound_endpoint || '—' }} +
+
+ +
+
{{ t('admin.ops.errorDetail.upstreamEndpoint') }}
+
+ {{ detail.upstream_endpoint || '—' }}
@@ -72,6 +93,13 @@ +
+
{{ t('admin.ops.errorDetail.requestType') }}
+
+ {{ formatRequestTypeLabel(detail.request_type) }} +
+
+
{{ t('admin.ops.errorDetail.message') }}
@@ -213,6 +241,15 @@ function isUpstreamError(d: OpsErrorDetail | null): boolean { return phase === 'upstream' && owner === 'provider' } +function formatRequestTypeLabel(type: number | null | undefined): string { + switch (type) { + case 1: return t('admin.ops.errorDetail.requestTypeSync') + case 2: return t('admin.ops.errorDetail.requestTypeStream') + case 3: return t('admin.ops.errorDetail.requestTypeWs') + default: return t('admin.ops.errorDetail.requestTypeUnknown') + } +} + const correlatedUpstream = ref([]) const correlatedUpstreamLoading = ref(false) diff --git a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue index 28868552..23377257 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue @@ -17,6 +17,9 @@ {{ t('admin.ops.errorLog.type') }} + + {{ t('admin.ops.errorLog.endpoint') }} + {{ t('admin.ops.errorLog.platform') }} @@ -42,7 +45,7 @@ - + {{ t('admin.ops.errorLog.noErrors') }} @@ -74,6 +77,18 @@ + + +
+ + + {{ log.inbound_endpoint }} + + + - +
+ + @@ -83,11 +98,22 @@ -
- - {{ log.model }} - - - +
+ +
@@ -138,6 +164,12 @@ > {{ log.severity }} + + {{ formatRequestType(log.request_type) }} +
@@ -193,6 +225,26 @@ function isUpstreamRow(log: OpsErrorLog): boolean { return phase === 'upstream' && owner === 'provider' } +function formatEndpointTooltip(log: OpsErrorLog): string { + const parts: string[] = [] + if (log.inbound_endpoint) parts.push(`Inbound: ${log.inbound_endpoint}`) + if (log.upstream_endpoint) parts.push(`Upstream: ${log.upstream_endpoint}`) + return parts.join('\n') || '' +} + +function displayModel(log: OpsErrorLog): string { + return log.requested_model || log.model || '' +} + +function formatRequestType(type: number | null | undefined): string { + switch (type) { + case 1: return t('admin.ops.errorLog.requestTypeSync') + case 2: return t('admin.ops.errorLog.requestTypeStream') + case 3: return t('admin.ops.errorLog.requestTypeWs') + default: return '' + } +} + function getTypeBadge(log: OpsErrorLog): { label: string; className: string } { const phase = String(log.phase || '').toLowerCase() const owner = String(log.error_owner || '').toLowerCase() From ecad083ffc414e197cf9cf3490f194c0eff32c7f Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 23 Mar 2026 15:50:12 +0800 Subject: [PATCH 06/15] fix(ops): prefer upstream_model in ops error displays --- .../ops/components/OpsErrorDetailModal.vue | 20 ++++++++++++-- .../admin/ops/components/OpsErrorLogTable.vue | 26 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index 4bcd0c41..d29607e5 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -59,13 +59,13 @@
{{ t('admin.ops.errorDetail.model') }}
-