feat(usage): 错误请求对齐用量明细(UI/排序/筛选/列设置)

错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab)
对齐用量明细的交互与信息密度。Squash of:

- feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列)
- refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort
- feat(usage): /admin/usage 错误请求新增分类过滤
- fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in
- fix(ops): 错误列表用户列回退显示已删除 KEY 所有者
- fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
This commit is contained in:
DaydreamCoding
2026-07-03 23:02:27 +08:00
parent 2649573b99
commit ebbdc70311
25 changed files with 1108 additions and 459 deletions
+41 -8
View File
@@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
}
// GetErrorLogs lists ops error logs.
// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
// Column whitelist and order normalization live in the repository; unknown
// values degrade to the default (created_at DESC), mirroring the usage list.
func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
}
// GET /api/v1/admin/ops/errors
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
if h.opsService == nil {
@@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
filter.Phase = ""
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
// 错误类型下拉选「上游」等于不过滤。
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
@@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
}
filter.View = "all"
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
filter.ClientRequestID = clientRequestID
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.View = parseOpsViewParam(c)
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
@@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
filter.StatusCodes = out
}
applyOpsErrorSortParams(c, filter)
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -322,6 +322,9 @@ func (h *UsageHandler) ListErrors(c *gin.Context) {
filter.ErrorTypesAny = types
}
// 排序对齐用量明细:列白名单与方向归一在 repo 层,非法值回退 created_at DESC。
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter)
if err != nil {
response.ErrorFrom(c, err)
@@ -85,10 +85,21 @@ func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) {
t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where)
}
// phase=upstream skips the status guard entirely — exemption is irrelevant there.
// phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard:
// request-error list endpoints filter by phase=upstream as a plain condition.
whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"})
if strings.Contains(whereUpstream, "status_code") {
t.Fatalf("upstream phase filter must not add any status_code clause\nfull: %s", whereUpstream)
if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") {
t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream)
}
if !strings.Contains(whereUpstream, "e.error_phase = $") {
t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream)
}
// phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard,
// exposing recovered (<400) upstream rows.
whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true})
if strings.Contains(whereRecovered, "status_code") {
t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered)
}
}
+54 -6
View File
@@ -177,6 +177,37 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any {
}
}
// opsErrorLogsOrderBy builds the ORDER BY clause from a whitelist, mirroring
// usageLogOrderBy semantics. Unknown SortBy falls back to created_at; e.id is
// always appended as tiebreaker for stable pagination.
func opsErrorLogsOrderBy(filter *service.OpsErrorLogFilter) string {
sortBy := ""
sortOrder := ""
if filter != nil {
sortBy = strings.ToLower(strings.TrimSpace(filter.SortBy))
sortOrder = strings.ToLower(strings.TrimSpace(filter.SortOrder))
}
var column string
switch sortBy {
case "model":
column = "COALESCE(NULLIF(TRIM(e.requested_model), ''), e.model)"
case "status_code":
// 与展示列/过滤保持同义:列表展示 COALESCE(upstream_status_code, status_code, 0),
// status_code 过滤也用同一表达式,故排序必须一致——否则 recovered upstream 行
//(status_code<400 但展示上游 5xx)排序键与显示值/分页切分不符。
column = "COALESCE(e.upstream_status_code, e.status_code, 0)"
default:
column = "e.created_at"
}
dir := "DESC"
if sortOrder == "asc" {
dir = "ASC"
}
return fmt.Sprintf("%s %s, e.id %s", column, dir, dir)
}
func (r *opsRepository) ListErrorLogs(ctx context.Context, filter *service.OpsErrorLogFilter) (*service.OpsErrorLogList, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("nil ops repository")
@@ -233,25 +264,29 @@ SELECT
COALESCE(a.name, ''),
e.group_id,
COALESCE(g.name, ''),
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
COALESCE(e.request_path, ''),
e.stream,
COALESCE(e.inbound_endpoint, ''),
COALESCE(e.upstream_endpoint, ''),
COALESCE(e.requested_model, ''),
COALESCE(e.upstream_model, ''),
COALESCE(e.user_agent, ''),
e.request_type,
COALESCE(ak.name, ''),
ak.deleted_at,
COALESCE(e.deleted_key_name, '')
COALESCE(e.deleted_key_name, ''),
e.deleted_key_owner_user_id,
COALESCE(du.email, '')
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
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
` + where + `
ORDER BY e.created_at DESC
ORDER BY ` + opsErrorLogsOrderBy(filter) + `
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
rows, err := r.db.QueryContext(ctx, selectSQL, argsWithLimit...)
@@ -279,6 +314,8 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
var apiKeyName string
var apiKeyDeletedAt sql.NullTime
var deletedKeyName string
var deletedKeyOwnerID sql.NullInt64
var deletedKeyOwnerEmail string
if err := rows.Scan(
&item.ID,
&item.CreatedAt,
@@ -311,10 +348,13 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
&item.UpstreamEndpoint,
&item.RequestedModel,
&item.UpstreamModel,
&item.UserAgent,
&requestType,
&apiKeyName,
&apiKeyDeletedAt,
&deletedKeyName,
&deletedKeyOwnerID,
&deletedKeyOwnerEmail,
); err != nil {
return nil, err
}
@@ -364,6 +404,12 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
}
// 已删除:ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。
item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "")
// 已删除 KEY 所有者快照:认证失败行 user_id 为空,列表用户列以此回退。
if deletedKeyOwnerID.Valid {
v := deletedKeyOwnerID.Int64
item.DeletedKeyOwnerUserID = &v
item.DeletedKeyOwnerEmail = deletedKeyOwnerEmail
}
out = append(out, &item)
}
if err := rows.Err(); err != nil {
@@ -417,7 +463,7 @@ SELECT
COALESCE(a.name, ''),
e.group_id,
COALESCE(g.name, ''),
CASE WHEN e.client_ip IS NULL THEN NULL ELSE e.client_ip::text END,
CASE WHEN e.client_ip IS NULL THEN NULL ELSE host(e.client_ip) END,
COALESCE(e.request_path, ''),
e.stream,
COALESCE(e.inbound_endpoint, ''),
@@ -927,12 +973,14 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
if filter != nil {
resolvedFilter = filter.Resolved
}
// Keep list endpoints scoped to client errors unless explicitly filtering upstream phase.
// Keep list endpoints scoped to client errors unless the caller explicitly opts
// into recovered upstream rows (Phase=="upstream" + IncludeRecoveredUpstream,
// ops 专用上游列表)。请求错误语义的端点即便过滤 phase=upstream 也保留该守卫。
// cyber_policy is exempt from the status >= 400 guard: streaming cyber hits arrive with
// status 200 (the SSE stream opened successfully before upstream returned response.failed),
// but they are always client-visible blocked requests that belong in admin + user error
// lists. Without the exemption the entire streaming-path cyber sink would be invisible.
if phaseFilter != "upstream" {
if phaseFilter != "upstream" || filter == nil || !filter.IncludeRecoveredUpstream {
clauses = append(clauses, "(COALESCE(e.status_code, 0) >= 400 OR e.error_type = 'cyber_policy')")
}
+34 -8
View File
@@ -1,6 +1,9 @@
package service
import "time"
import (
"strings"
"time"
)
type OpsSystemLog struct {
ID int64 `json:"id"`
@@ -65,17 +68,22 @@ type OpsErrorLog struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
RequestType *int16 `json:"request_type"`
UserAgent string `json:"user_agent"`
// 关联 api_key 名称(LEFT JOIN api_keys 取得;软删只覆盖 key 列,name 保留,故已删 key 仍有原名)。
APIKeyName string `json:"api_key_name,omitempty"`
APIKeyDeleted bool `json:"api_key_deleted,omitempty"`
// 已删除 KEY 所有者(INVALID_API_KEY 且该 key 曾存在时的归因快照)。
// 认证失败行 user_id 为空,列表用户列以此回退显示所有者。
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
}
type OpsErrorLogDetail struct {
OpsErrorLog
ErrorBody string `json:"error_body"`
UserAgent string `json:"user_agent"`
// Upstream context (optional)
UpstreamStatusCode *int `json:"upstream_status_code,omitempty"`
@@ -93,11 +101,10 @@ type OpsErrorLogDetail struct {
// vNext metric semantics
IsBusinessLimited bool `json:"is_business_limited"`
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted)
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
DeletedKeyName string `json:"deleted_key_name,omitempty"`
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted).
// OwnerUserID/OwnerEmail 已上移到 OpsErrorLog(列表用户列回退需要)。
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
DeletedKeyName string `json:"deleted_key_name,omitempty"`
// Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix.
APIKeyPrefix string `json:"api_key_prefix,omitempty"`
@@ -142,8 +149,14 @@ type OpsErrorLogFilter struct {
// ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true).
ExcludeCountTokens bool
// IncludeRecoveredUpstream 显式豁免 status>=400 守卫(仅在 Phase=="upstream" 时生效):
// ops 专用上游错误列表需要看到 status<400 的 recovered upstream 行。
// 请求错误语义的端点不设此开关,phase=upstream 过滤照常生效且守卫保留。
IncludeRecoveredUpstream bool
// ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the
// special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause).
// special-cased single `Phase` field (only Phase=="upstream" with
// IncludeRecoveredUpstream bypasses the status>=400 clause).
// NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream'
// but status_code<400 (recovered upstream errors) remain excluded.
// Used to map user-facing coarse categories to backend conditions.
@@ -158,6 +171,19 @@ type OpsErrorLogFilter struct {
Page int
PageSize int
// SortBy/SortOrder: server-side sorting aligned with the usage-log list.
// Repo whitelists columns (created_at/model/status_code); anything else
// falls back to created_at. SortOrder is "asc"/"desc" (default desc).
SortBy string
SortOrder string
}
// SetSort normalizes raw sort_by/sort_order query values into the filter.
// Shared by the admin and user-facing error list handlers.
func (f *OpsErrorLogFilter) SetSort(sortBy, sortOrder string) {
f.SortBy = strings.TrimSpace(sortBy)
f.SortOrder = strings.TrimSpace(sortOrder)
}
type OpsErrorLogList struct {
+5 -3
View File
@@ -359,10 +359,12 @@ func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, fi
filter.UserQuery = ""
filter.Owner = ""
filter.Source = ""
// 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。
// 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句),
// 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
// 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny
//(纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要
// IncludeRecoveredUpstream(用户端永不设置),recovered upstream
//(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
filter.Phase = ""
filter.IncludeRecoveredUpstream = false
list, err := s.opsRepo.ListErrorLogs(ctx, filter)
if err != nil {
@@ -184,16 +184,16 @@ func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) {
mk := func() *OpsErrorLogDetail {
return &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
DeletedKeyOwnerUserID: &ownerUID,
},
DeletedKeyOwnerUserID: &ownerUID,
}
}
+19 -2
View File
@@ -3,9 +3,12 @@ package service
import "time"
// UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。
// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint /
// user_email 等敏感或内部字段。注:message(网关标准化错误描述)与 key_name
// 严禁包含 account / api_key_prefix / upstream_endpoint / user_email 等
// 敏感或内部字段。注:message(网关标准化错误描述)与 key_name
// (用户自有 API Key 名称,KeysView 中本就可见)经产品决策对该用户开放;
// client_ip / user_agent / group_name / request_type / stream 均为该用户
// 自己请求的属性,经产品决策(2026-07-03)开放,
// 与用量明细已向用户展示自身 ip_address/user_agent/分组/类型 的口径对齐;
// error_body 仅在详情接口(GetUserErrorRequestDetail)按归属校验后返回。
type UserErrorRequest struct {
ID int64 `json:"id"`
@@ -18,6 +21,11 @@ type UserErrorRequest struct {
Message string `json:"message"`
KeyName string `json:"key_name"`
KeyDeleted bool `json:"key_deleted"`
ClientIP string `json:"client_ip,omitempty"`
GroupName string `json:"group_name,omitempty"`
RequestType *int16 `json:"request_type,omitempty"`
Stream bool `json:"stream"`
UserAgent string `json:"user_agent,omitempty"`
}
// UserErrorRequestList 是用户错误请求分页结果。
@@ -90,6 +98,10 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
if model == "" {
model = e.Model
}
clientIP := ""
if e.ClientIP != nil {
clientIP = *e.ClientIP
}
return &UserErrorRequest{
ID: e.ID,
CreatedAt: e.CreatedAt,
@@ -101,6 +113,11 @@ func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
Message: e.Message,
KeyName: e.APIKeyName,
KeyDeleted: e.APIKeyDeleted,
ClientIP: clientIP,
GroupName: e.GroupName,
RequestType: e.RequestType,
Stream: e.Stream,
UserAgent: e.UserAgent,
}
}
@@ -122,9 +122,11 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
UserEmail: "secret@example.com",
ClientIP: func() *string { s := "1.2.3.4"; return &s }(),
UpstreamEndpoint: "https://api.openai.com/v1/chat/completions",
UserAgent: "codex_cli_rs/0.125.0",
GroupName: "grp-a",
Stream: true,
},
ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`,
UserAgent: "Mozilla/5.0 secret-agent",
UpstreamStatusCode: &upstreamStatus,
}
@@ -147,13 +149,27 @@ func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
t.Errorf("UpstreamStatusCode mismatch")
}
// client_ip / user_agent / group_name / stream 经产品决策开放(与用量明细口径对齐)
if out.ClientIP != "1.2.3.4" {
t.Errorf("want client_ip=1.2.3.4, got %q", out.ClientIP)
}
if out.UserAgent != "codex_cli_rs/0.125.0" {
t.Errorf("want user_agent=codex_cli_rs/0.125.0, got %q", out.UserAgent)
}
if out.GroupName != "grp-a" {
t.Errorf("want group_name=grp-a, got %q", out.GroupName)
}
if !out.Stream {
t.Errorf("want stream=true")
}
// 序列化后不含敏感字段
b, err := json.Marshal(out)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
raw := string(b)
for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} {
for _, forbidden := range []string{"user_email", "upstream_endpoint"} {
if strings.Contains(raw, forbidden) {
t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw)
}