// 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("") } 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 "" } return safePayload(raw) } raw, err := json.Marshal(value) if err != nil { return "" } 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 }