239 lines
5.9 KiB
Go
239 lines
5.9 KiB
Go
// Package logx 提供服务边界日志能力。
|
||
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。
|
||
package logx
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"log/slog"
|
||
"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
|
||
}
|
||
|
||
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
|
||
}
|