2026-05-02 13:02:38 +08:00

245 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package logx 提供服务边界日志能力。
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。
package logx
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"mime"
"net/http"
"strings"
"time"
"hyapp/pkg/xerr"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const (
// maxLoggedBytes 限制单个 request/response 字段的日志体积,避免大响应污染 stdout。
maxLoggedBytes = 8192
// redactedValue 是敏感字段统一替换值,保证日志可读但不可用于重放凭证。
redactedValue = "***REDACTED***"
)
// HTTPMiddleware 在 gateway HTTP 边界记录请求体、响应体、状态码和耗时。
// requestIDFromContext 由 gateway 注入logx 不直接依赖具体 transport 包,避免反向耦合。
func HTTPMiddleware(service string, requestIDFromContext func(context.Context) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
requestBody := readAndRestoreBody(request)
capture := newHTTPResponseCapture(writer)
next.ServeHTTP(capture, request)
requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
args := []any{
"service", service,
"request_id", requestID,
"method", request.Method,
"path", request.URL.Path,
"query", request.URL.RawQuery,
"status", capture.statusCode,
"duration_ms", time.Since(startedAt).Milliseconds(),
"request", safePayload(requestBody),
"response", safePayload(capture.body.Bytes()),
}
if capture.statusCode >= http.StatusInternalServerError {
slog.Error("http_access", args...)
return
}
slog.Info("http_access", args...)
})
}
// UnaryServerInterceptor 在内部 gRPC 服务边界记录 protobuf request/response。
// 内部链路统一 gRPC + protobuf因此这里是排查跨服务参数和返回值的主入口。
func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") {
// 健康检查高频且无业务参数,跳过可避免日志噪音淹没真实业务请求。
return handler(ctx, request)
}
startedAt := time.Now()
response, err := handler(ctx, request)
code := codes.OK
if err != nil {
code = status.Code(err)
}
args := []any{
"service", service,
"request_id", requestIDFromPayload(request),
"method", info.FullMethod,
"grpc_code", code.String(),
"reason", string(xerr.ReasonFromGRPC(err)),
"duration_ms", time.Since(startedAt).Milliseconds(),
"request", safePayloadFromValue(request),
"response", safePayloadFromValue(response),
}
if err != nil {
slog.Error("grpc_access", args...)
return response, err
}
slog.Info("grpc_access", args...)
return response, nil
}
}
type httpResponseCapture struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
}
func newHTTPResponseCapture(writer http.ResponseWriter) *httpResponseCapture {
return &httpResponseCapture{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
}
func (c *httpResponseCapture) WriteHeader(statusCode int) {
c.statusCode = statusCode
c.ResponseWriter.WriteHeader(statusCode)
}
func (c *httpResponseCapture) Write(data []byte) (int, error) {
if c.body.Len() < maxLoggedBytes {
remaining := maxLoggedBytes - c.body.Len()
if len(data) > remaining {
c.body.Write(data[:remaining])
} else {
c.body.Write(data)
}
}
return c.ResponseWriter.Write(data)
}
func readAndRestoreBody(request *http.Request) []byte {
if request.Body == nil {
return nil
}
mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
if strings.HasPrefix(mediaType, "multipart/") {
// 文件上传请求体可能很大,且包含二进制内容;日志只记录占位符并保留原始流给 handler。
return []byte("<multipart body omitted>")
}
body, err := io.ReadAll(request.Body)
if err != nil {
// 读取失败时把空 body 交还给后续 handler让真实解码错误仍由 handler 返回。
request.Body = io.NopCloser(bytes.NewReader(nil))
return nil
}
request.Body.Close()
request.Body = io.NopCloser(bytes.NewReader(body))
return body
}
func safePayloadFromValue(value any) any {
if value == nil {
return nil
}
if message, ok := value.(proto.Message); ok {
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
if err != nil {
return "<proto_marshal_failed>"
}
return safePayload(raw)
}
raw, err := json.Marshal(value)
if err != nil {
return "<json_marshal_failed>"
}
return safePayload(raw)
}
func requestIDFromPayload(value any) string {
payload, ok := safePayloadFromValue(value).(map[string]any)
if !ok {
return ""
}
meta, ok := payload["meta"].(map[string]any)
if !ok {
return ""
}
if requestID, ok := meta["request_id"].(string); ok {
return requestID
}
return ""
}
func safePayload(raw []byte) any {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil
}
if len(raw) > maxLoggedBytes {
raw = raw[:maxLoggedBytes]
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return string(raw)
}
return redactValue(payload)
}
func redactValue(value any) any {
switch typed := value.(type) {
case map[string]any:
redacted := make(map[string]any, len(typed))
for key, field := range typed {
if isSensitiveKey(key) {
redacted[key] = redactedValue
continue
}
redacted[key] = redactValue(field)
}
return redacted
case []any:
for index, item := range typed {
typed[index] = redactValue(item)
}
return typed
default:
return value
}
}
func isSensitiveKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
sensitiveParts := []string{
"password",
"credential",
"secret",
"token",
"access_token",
"refresh_token",
"id_token",
"user_sig",
"usersig",
"authorization",
"signature",
"private_key",
"callback_auth",
}
for _, part := range sensitiveParts {
if strings.Contains(normalized, part) {
return true
}
}
return false
}