377 lines
9.9 KiB
Go
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
|
|
}
|