improve(log):优化日志打印

This commit is contained in:
qian committed 2026-08-02 15:45:07 +08:00
1 parent 03259a144a
commit d7b034c50e
7 files changed
+271 -10

No files matched your search

+5 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
"github.com/qianfree/team-api/relay/helper"
)
// HandleChatCompletions 处理 /v1/chat/completions 请求
@@ -157,7 +158,7 @@ func WriteRelayError(w http.ResponseWriter, err error) {
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
errMsg := err.Error()
errMsg := helper.SafeUpstreamErrorMessage(err)
errType := "internal_error"
if errors.As(err, &rateLimitErr) {
@@ -171,7 +172,9 @@ func WriteRelayError(w http.ResponseWriter, err error) {
statusCode = relayErr.StatusCode
errMsg = relayErr.Message
if relayErr.Cause != nil {
errMsg = relayErr.Message + ": " + relayErr.Cause.Error()
// 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户;
// 日志侧仍用 originalError=%v 打印完整错误供运维定位。
errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause)
}
errType = relayErr.Type
}
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/helper"
)
// HandleClaudeMessages 处理 /v1/messages 请求(Claude 原生格式)
@@ -37,7 +38,7 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) {
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
errMsg := err.Error()
errMsg := helper.SafeUpstreamErrorMessage(err)
errType := "api_error"
if errors.As(err, &rateLimitErr) {
@@ -48,7 +49,9 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) {
statusCode = relayErr.StatusCode
errMsg = relayErr.Message
if relayErr.Cause != nil {
errMsg = relayErr.Message + ": " + relayErr.Cause.Error()
// 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户;
// 日志侧仍用 originalError=%v 打印完整错误供运维定位。
errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause)
}
errType = relayErr.Type
}
+5 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
"github.com/qianfree/team-api/relay/helper"
)
// HandleGeminiGenerateContent 处理 /v1beta/models/{model}:generateContent 请求(Gemini 原生格式)
@@ -74,7 +75,7 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) {
var relayErr *constant.RelayError
var rateLimitErr *RelayErrorWithRateLimit
statusCode := http.StatusInternalServerError
errMsg := err.Error()
errMsg := helper.SafeUpstreamErrorMessage(err)
errStatus := "INTERNAL"
if errors.As(err, &rateLimitErr) {
@@ -85,7 +86,9 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) {
statusCode = relayErr.StatusCode
errMsg = relayErr.Message
if relayErr.Cause != nil {
errMsg = relayErr.Message + ": " + relayErr.Cause.Error()
// 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户;
// 日志侧仍用 originalError=%v 打印完整错误供运维定位。
errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause)
}
switch statusCode {
case 401:
+4 -1
View File
@@ -639,7 +639,10 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (will retry): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s",
adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision)
} else {
g.Log().Errorf(ctx, "[RelayHandler] DoResponse failed (abort): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s",
// 上游响应错误属于预期内的运营事件(4xx/5xx/超时等),非代码 bug:
// 用 Warningf 避免触发 glog 默认对 ERROR+ 级别自动打印调用栈(StStatus=1),
// 堆栈只会复述对所有请求都一样的中间件链,污染日志且无诊断价值。
g.Log().Warningf(ctx, "[RelayHandler] DoResponse failed (abort): adaptor=%s, inboundFormat=%s, channel=%d(%s) model=%s attempt=%d error=%v latency=%.0fms decision=%s",
adaptor.GetChannelName(), info.InboundFormat, selection.ChannelID, selection.ChannelName, v.modelName, attempt, err, info.LatencyMs(), decision)
}
channelErrors = append(channelErrors, fmt.Sprintf("attempt=%d channel=%d(%s) model=%s doResponse_error=[%v] latency=%.0fms",
+8 -3
View File
@@ -16,6 +16,7 @@ import (
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
"github.com/qianfree/team-api/relay/helper"
"github.com/qianfree/team-api/relay/taskchannel"
_ "github.com/qianfree/team-api/relay/taskchannel/ali"
_ "github.com/qianfree/team-api/relay/taskchannel/gemini"
@@ -211,8 +212,10 @@ func HandleTaskSubmit(
if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil {
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err)
}
g.Log().Errorf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err)
writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+err.Error(), "")
// 上游请求失败(网络/超时/拒绝等)属预期内运营事件,非代码 bug:
// 用 Warningf 避免 glog 对 ERROR+ 自动打印调用栈污染日志(与 :229 upstream response error 一致)。
g.Log().Warningf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err)
writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+helper.SafeUpstreamErrorMessage(err), "")
return
}
defer resp.Body.Close()
@@ -379,10 +382,12 @@ func isSyncImageResultTask(t *common.AsyncTask) bool {
// writeTaskError 写入错误响应
func writeTaskError(w http.ResponseWriter, statusCode int, message, code string) {
// 防御性兜底:抹除 message 中可能残留的上游 URL / IP(如 DoRequest 失败的 *url.Error、
// MJ 图片代理的 http.Get 错误)。传输层域名已在主要泄露点(:215)经 SafeUpstreamErrorMessage 归一化。
resp := map[string]any{
"error": map[string]any{
"type": "invalid_request_error",
"message": message,
"message": helper.RedactMessage(message),
},
}
if code != "" {
+96
View File
@@ -0,0 +1,96 @@
package helper
import (
"context"
"errors"
"io"
"net"
"net/url"
"regexp"
"syscall"
)
// 用户可见错误消息中的敏感信息替换占位符。
const redactedPlaceholder = "[redacted]"
var (
// redactURLPattern 匹配 http(s):// 开头的 URL(含 host、path、query)。
redactURLPattern = regexp.MustCompile(`(?i)https?://[^\s"'<>]+`)
// redactIPPattern 匹配 IPv4 地址。版本号偶有误伤,但在错误消息中无害。
redactIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
)
// SafeUpstreamErrorMessage 把可能包含上游渠道域名 / 内部传输细节的错误,
// 转换为对最终用户安全的消息文本。
//
// 设计原则:
// - 传输层错误(client.Do 返回的 *url.Error)按类别归一化,保留
// "超时 / 拒绝 / DNS / 重置" 等对用户有用的类别信息,但彻底抹掉
// 上游域名与 URL(这是本修复的核心目标)。
// - 其余错误(业务错误、上游响应体解析错误等)保留原始消息,但做
// URL / IP 抹除作为防御性兜底。
//
// 日志侧不受影响:调用方写日志时应使用原始 err(%v),保留完整诊断信息。
func SafeUpstreamErrorMessage(err error) string {
if err == nil {
return ""
}
// 传输层错误:http.Client.Do 失败时返回 *url.Error,其 Error() 形如
// `Post "https://api.openai.com/v1/chat/completions": dial tcp: ...`,
// 完整暴露上游域名,必须归一化。
var urlErr *url.Error
if errors.As(err, &urlErr) {
return categorizeTransportError(urlErr)
}
// 非传输层错误:保留消息,但抹除其中可能残留的 URL / IP。
return RedactMessage(err.Error())
}
// categorizeTransportError 按 *url.Error 内部成因归类为通用消息,不泄露域名。
func categorizeTransportError(urlErr *url.Error) string {
inner := urlErr.Err
// 客户端主动取消:不可重试,向用户说明即可
if errors.Is(inner, context.Canceled) {
return "request canceled"
}
// 请求 / 响应超时(context 截止、i/o timeout、Client.Timeout 等)
if errors.Is(inner, context.DeadlineExceeded) {
return "upstream request timed out"
}
var netErr net.Error
if errors.As(inner, &netErr) && netErr.Timeout() {
return "upstream request timed out"
}
// 连接被上游拒绝(端口未开放 / 服务未启动)
if errors.Is(inner, syscall.ECONNREFUSED) {
return "upstream connection refused"
}
// DNS 解析失败:域名不存在或解析异常
var dnsErr *net.DNSError
if errors.As(inner, &dnsErr) {
return "upstream host resolution failed"
}
// 连接被对端重置 / 提前关闭(GFW 干扰、上游瞬断的典型表现)
if errors.Is(inner, syscall.ECONNRESET) ||
errors.Is(inner, io.EOF) ||
errors.Is(inner, io.ErrUnexpectedEOF) {
return "upstream connection reset"
}
// 其余无法归类的传输层错误
return "upstream connection failed"
}
// RedactMessage 全量抹除字符串中的 URL / IP,替换为 [redacted]。
// 用作非传输层错误的防御性兜底(传输层域名已由 SafeUpstreamErrorMessage
// 的结构化分类兜住,此处只处理偶发含 URL/IP 的业务错误消息)。
func RedactMessage(msg string) string {
if msg == "" {
return msg
}
msg = redactURLPattern.ReplaceAllString(msg, redactedPlaceholder)
msg = redactIPPattern.ReplaceAllString(msg, redactedPlaceholder)
return msg
}
+148
View File
@@ -0,0 +1,148 @@
package helper
import (
"context"
"errors"
"io"
"net"
"net/url"
"strings"
"syscall"
"testing"
"github.com/qianfree/team-api/relay/constant"
)
// fakeTimeoutError 是一个独立的 net.Error 实现,Timeout() 返回 true,
// 用于验证 categorizeTransportError 中 net.Error.Timeout() 分支(区别于 context.DeadlineExceeded)。
type fakeTimeoutError struct{}
func (fakeTimeoutError) Error() string { return "i/o timeout" }
func (fakeTimeoutError) Timeout() bool { return true }
func (fakeTimeoutError) Temporary() bool { return true }
// 上游域名泄露的典型形态:client.Do 失败返回的 *url.Error 含完整 URL。
func leakyURLError(inner error) *url.Error {
return &url.Error{
Op: "Post",
URL: "https://api.openai.com/v1/chat/completions",
Err: inner,
}
}
func TestSafeUpstreamErrorMessage_TransportCategories(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{"超时-context截止", leakyURLError(context.DeadlineExceeded), "upstream request timed out"},
{"超时-i/o timeout", leakyURLError(fakeTimeoutError{}), "upstream request timed out"},
{"客户端取消", leakyURLError(context.Canceled), "request canceled"},
{"连接拒绝", leakyURLError(syscall.ECONNREFUSED), "upstream connection refused"},
{"DNS失败", leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}), "upstream host resolution failed"},
{"连接重置", leakyURLError(syscall.ECONNRESET), "upstream connection reset"},
{"EOF", leakyURLError(io.EOF), "upstream connection reset"},
{"意外EOF", leakyURLError(io.ErrUnexpectedEOF), "upstream connection reset"},
{"其余传输层错误", leakyURLError(errors.New("tls: handshake failure")), "upstream connection failed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := SafeUpstreamErrorMessage(c.err)
if got != c.want {
t.Fatalf("got %q, want %q", got, c.want)
}
// 核心安全断言:任何传输层错误的归一化结果都不得包含上游域名或协议
if strings.Contains(got, "openai.com") {
t.Errorf("结果泄露了上游域名: %q", got)
}
if strings.Contains(got, "http") {
t.Errorf("结果泄露了协议/URL: %q", got)
}
})
}
}
func TestSafeUpstreamErrorMessage_Nil(t *testing.T) {
if got := SafeUpstreamErrorMessage(nil); got != "" {
t.Fatalf("nil 应返回空串,got %q", got)
}
}
func TestSafeUpstreamErrorMessage_BusinessErrorPreservedButRedacted(t *testing.T) {
// 非传输层错误:消息保留,但其中的 URL/IP 必须被抹除
err := errors.New(`dial upstream websocket failed: Get "https://api.x.com/v1": connection refused at 10.0.0.5`)
got := SafeUpstreamErrorMessage(err)
if strings.Contains(got, "https://api.x.com") {
t.Errorf("URL 未被抹除: %q", got)
}
if strings.Contains(got, "10.0.0.5") {
t.Errorf("IP 未被抹除: %q", got)
}
if !strings.Contains(got, "dial upstream websocket failed") {
t.Errorf("业务消息正文不应被破坏: %q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("应包含 [redacted] 占位: %q", got)
}
}
func TestSafeUpstreamErrorMessage_LegitimateBusinessMessageUntouched(t *testing.T) {
// 上游 provider 的合法业务错误(无 URL/IP)应原样保留
cases := []string{
"rate limit exceeded",
"invalid model: gpt-5-mini",
"This model's maximum context length is 8192 tokens.",
}
for _, msg := range cases {
got := SafeUpstreamErrorMessage(errors.New(msg))
if got != msg {
t.Errorf("合法消息被改动: got %q, want %q", got, msg)
}
}
}
func TestSafeUpstreamErrorMessage_WrappedRelayError(t *testing.T) {
// 端到端:编排层把 *url.Error 塞进 NewUpstreamError 的 Cause,
// 传入 SafeUpstreamErrorMessage 应能经 errors.As 找到 *url.Error 并归一化。
relayErr := constant.NewUpstreamError(502, "upstream request failed",
leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}))
got := SafeUpstreamErrorMessage(relayErr)
if got != "upstream host resolution failed" {
t.Fatalf("经 RelayError 包装后归一化失败,got %q", got)
}
if strings.Contains(got, "openai.com") || strings.Contains(got, "https") {
t.Errorf("仍泄露上游信息: %q", got)
}
}
func TestRedactMessage(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{"no sensitive info here", "no sensitive info here"},
{
`Post "https://api.openai.com/v1/chat": dial tcp: i/o timeout`,
`Post "[redacted]": dial tcp: i/o timeout`,
},
{
`connect to http://10.0.0.5:8080 failed`,
`connect to [redacted] failed`,
},
{
"https://sub.example.co.uk/a/b?x=1 and 192.168.1.1 both redacted",
"[redacted] and [redacted] both redacted",
},
}
for _, c := range cases {
got := RedactMessage(c.in)
if got != c.want {
t.Errorf("RedactMessage(%q)\n got %q\n want %q", c.in, got, c.want)
}
}
}