Files
dygou_go/internal/trace/trace.go
T
2026-08-08 17:40:16 +08:00

377 lines
9.9 KiB
Go

package trace
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"douyu_go/internal/constants"
"douyu_go/internal/logger"
"douyu_go/internal/types"
"douyu_go/internal/utils"
)
// Recorder HTTP 请求追踪记录器。
type Recorder struct {
Enabled bool
MaxBodyBytes int
writer *logger.AsyncLogWriter
filePath string
seq uint64
}
type entry struct {
Seq uint64
Time string
Label string
Method string
URL string
Path string
Status int
Cost time.Duration
ReqHeader map[string][]string
RespHeader map[string][]string
ReqBody string
RespBody string
APIError int
APIMessage string
ErrorText string
MaxBodyBytes int
}
// InitTrace 初始化追踪记录器。
func InitTrace(logDir string, cfg types.TraceConfig) (*Recorder, func()) {
if !cfg.Enabled {
return &Recorder{Enabled: false}, func() {}
}
if logDir == "" {
logDir = constants.DefaultLogDir
}
if err := os.MkdirAll(logDir, 0755); err != nil {
logger.LogEvent("Trace", "error", "trace 目录创建失败,已关闭 trace: %v", err)
return &Recorder{Enabled: false}, func() {}
}
path := filepath.Join(logDir, "douyu_trace_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
logger.LogEvent("Trace", "error", "trace 文件创建失败,已关闭 trace: %v", err)
return &Recorder{Enabled: false}, func() {}
}
recorder := &Recorder{
Enabled: true,
MaxBodyBytes: cfg.MaxBodyBytes,
writer: logger.NewAsyncLogWriter(file, 2048),
filePath: path,
}
if cfg.MaxBodyBytes > 0 {
logger.LogEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes)
} else {
logger.LogEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path)
}
return recorder, func() {
_ = recorder.Close()
_ = file.Close()
}
}
// Close 关闭记录器。
func (t *Recorder) Close() error {
if t == nil || t.writer == nil {
return nil
}
return t.writer.Close()
}
// Record 记录一次请求。
func (t *Recorder) Record(label string, req *http.Request, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) {
t.RecordResponse(label, req, nil, status, cost, reqBody, respBody, errText, apiError, apiMsg)
}
// RecordResponse 记录一次请求和响应。
func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Response, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) {
if t == nil || !t.Enabled || req == nil || req.URL == nil {
return
}
if label == "服务时间" && status == http.StatusOK && errText == "" && apiError == 0 {
return
}
e := entry{
Seq: atomic.AddUint64(&t.seq, 1),
Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"),
Label: label,
Method: req.Method,
URL: redactedURL(req.URL),
Path: req.URL.Path,
Status: status,
Cost: cost,
ReqHeader: sanitizeHeader(req.Header),
RespHeader: sanitizeResponseHeader(resp),
ReqBody: sanitizeBodyForTrace(reqBody, req.Header.Get("Content-Type"), t.MaxBodyBytes),
RespBody: sanitizeResponseBody(respBody, t.MaxBodyBytes),
APIError: apiError,
APIMessage: apiMsg,
ErrorText: errText,
MaxBodyBytes: t.MaxBodyBytes,
}
_, _ = t.writer.Write([]byte(formatEntry(e)))
}
// redactedURL 隐藏请求 URL 中的账号、角色与签名参数,避免 trace 落盘敏感信息。
func redactedURL(raw *url.URL) string {
if raw == nil {
return ""
}
copyURL := *raw
query := copyURL.Query()
for key := range query {
switch strings.ToLower(key) {
case "userid", "roleid", "openid", "code", "sig", "token", "sid":
query.Set(key, "<hidden>")
}
}
copyURL.RawQuery = query.Encode()
return copyURL.String()
}
// RequestBody 读取请求体用于追踪。
func (t *Recorder) RequestBody(req *http.Request) []byte {
if t == nil || !t.Enabled || req == nil || req.GetBody == nil {
return nil
}
body, err := req.GetBody()
if err != nil {
return nil
}
defer body.Close()
if t.MaxBodyBytes > 0 {
data, _ := io.ReadAll(io.LimitReader(body, int64(t.MaxBodyBytes)+1))
return data
}
data, _ := io.ReadAll(body)
return data
}
func sanitizeHeader(header http.Header) map[string][]string {
out := make(map[string][]string, len(header))
for key, values := range header {
canonical := http.CanonicalHeaderKey(key)
copied := make([]string, len(values))
copy(copied, values)
out[canonical] = copied
}
return out
}
func sanitizeResponseHeader(resp *http.Response) map[string][]string {
if resp == nil {
return nil
}
out := sanitizeHeader(resp.Header)
if len(out) == 0 {
return out
}
if _, ok := out["Set-Cookie"]; ok {
out["Set-Cookie"] = summarizeSetCookies(resp)
}
return out
}
func summarizeSetCookies(resp *http.Response) []string {
if resp == nil {
return nil
}
cookies := resp.Cookies()
if len(cookies) == 0 {
values := resp.Header.Values("Set-Cookie")
summary := make([]string, 0, len(values))
for range values {
summary = append(summary, "<hidden>")
}
return summary
}
summary := make([]string, 0, len(cookies))
for _, ck := range cookies {
if ck == nil || ck.Name == "" {
continue
}
parts := []string{ck.Name + "=<hidden>"}
if ck.Path != "" {
parts = append(parts, "Path="+ck.Path)
}
if ck.Domain != "" {
parts = append(parts, "Domain="+ck.Domain)
}
if ck.MaxAge != 0 {
parts = append(parts, fmt.Sprintf("Max-Age=%d", ck.MaxAge))
}
if !ck.Expires.IsZero() {
parts = append(parts, "Expires="+ck.Expires.In(constants.CST).Format(time.RFC3339))
}
if ck.HttpOnly {
parts = append(parts, "HttpOnly")
}
if ck.Secure {
parts = append(parts, "Secure")
}
summary = append(summary, strings.Join(parts, "; "))
}
if len(summary) == 0 {
return []string{"<hidden>"}
}
sort.Strings(summary)
return summary
}
func sanitizeBodyForTrace(body []byte, contentType string, max int) string {
if len(body) == 0 {
return ""
}
mediaType, params, _ := mime.ParseMediaType(contentType)
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary != "" {
return sanitizeMultipartBody(body, boundary, max)
}
}
return sanitizeTextBody(body, max)
}
func sanitizeResponseBody(body []byte, max int) string {
return sanitizeTextBody(body, max)
}
func sanitizeTextBody(body []byte, max int) string {
raw := string(body)
if pretty, ok := prettyJSON(raw); ok {
return truncateText(pretty, max)
}
return truncateText(raw, max)
}
func sanitizeMultipartBody(body []byte, boundary string, max int) string {
reader := multipart.NewReader(bytes.NewReader(body), boundary)
fields := make(map[string][]string)
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return truncateText(string(body), max)
}
name := part.FormName()
if name == "" {
continue
}
var valueBytes []byte
if max > 0 {
valueBytes, _ = io.ReadAll(io.LimitReader(part, int64(max)+1))
} else {
valueBytes, _ = io.ReadAll(part)
}
value := truncateText(string(valueBytes), max)
fields[name] = append(fields[name], value)
}
encoded, err := json.Marshal(fields)
if err != nil {
return ""
}
return string(encoded)
}
func truncateText(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("...<truncated %d bytes>", len(s)-max)
}
func formatEntry(e entry) string {
var b strings.Builder
statusText := "成功"
if e.Status < 200 || e.Status >= 300 || e.APIError != 0 || e.ErrorText != "" {
statusText = "异常"
}
b.WriteString(strings.Repeat("=", 96))
b.WriteByte('\n')
fmt.Fprintf(&b, "#%06d %s [%s] %s %s\n", e.Seq, e.Time, statusText, e.Method, e.URL)
fmt.Fprintf(&b, "接口: %s\n", utils.EmptyDefault(e.Label, "-"))
fmt.Fprintf(&b, "路径: %s\n", utils.EmptyDefault(e.Path, "-"))
fmt.Fprintf(&b, "结果: HTTP %d | 耗时 %s | apiError=%d", e.Status, utils.RoundDuration(e.Cost), e.APIError)
if e.APIMessage != "" {
fmt.Fprintf(&b, " | msg=%s", e.APIMessage)
}
b.WriteByte('\n')
if e.ErrorText != "" {
fmt.Fprintf(&b, "错误: %s\n", e.ErrorText)
}
b.WriteString("\n[响应头]\n")
b.WriteString(formatHeaders(e.RespHeader))
b.WriteString("\n[请求头]\n")
b.WriteString(formatHeaders(e.ReqHeader))
b.WriteString("\n[请求体]\n")
b.WriteString(formatBody(e.ReqBody))
b.WriteString("\n[响应体]\n")
b.WriteString(formatBody(e.RespBody))
if e.MaxBodyBytes > 0 {
fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值。\n", e.MaxBodyBytes)
} else {
b.WriteString("\n提示: trace 全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值。\n")
}
b.WriteByte('\n')
return b.String()
}
func formatHeaders(headers map[string][]string) string {
if len(headers) == 0 {
return "<empty>\n"
}
keys := make([]string, 0, len(headers))
for key := range headers {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
fmt.Fprintf(&b, "%s: %s\n", key, strings.Join(headers[key], ", "))
}
return b.String()
}
func formatBody(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return "<empty>\n"
}
if pretty, ok := prettyJSON(body); ok {
return pretty + "\n"
}
return body + "\n"
}
func prettyJSON(s string) (string, bool) {
var value any
if err := json.Unmarshal([]byte(s), &value); err != nil {
return "", false
}
out, err := json.MarshalIndent(value, "", " ")
if err != nil {
return "", false
}
return string(out), true
}