2026-05-12 09:53:20 +08:00

634 lines
17 KiB
Go

// Package logx provides structured runtime logging for service boundaries and workers.
// Services emit JSON/text to stdout only; deployment collects stdout into CLS or local logs.
package logx
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/slog"
"mime"
"net/http"
"os"
"strings"
"sync"
"time"
"hyapp/pkg/appcode"
"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 (
defaultLevel = "info"
defaultFormat = "json"
defaultEnv = "local"
defaultMaxPayloadBytes = 2048
redactedValue = "***REDACTED***"
)
// Config controls process-wide structured logging. Service code must not write to CLS directly.
type Config struct {
Service string `yaml:"service"`
Env string `yaml:"env"`
Version string `yaml:"version"`
NodeID string `yaml:"node_id"`
Level string `yaml:"level"`
Format string `yaml:"format"`
IncludeRequestBody bool `yaml:"include_request_body"`
IncludeResponseBody bool `yaml:"include_response_body"`
MaxPayloadBytes int `yaml:"max_payload_bytes"`
Output io.Writer `yaml:"-"`
}
// WithRuntimeFields fills process identity fields from service config without duplicating YAML.
func (cfg Config) WithRuntimeFields(service string, env string, nodeID string) Config {
if strings.TrimSpace(cfg.Service) == "" {
cfg.Service = service
}
if strings.TrimSpace(cfg.Env) == "" {
cfg.Env = env
}
if strings.TrimSpace(cfg.NodeID) == "" {
cfg.NodeID = nodeID
}
return cfg
}
type contextAttrsKey struct{}
var (
globalMu sync.RWMutex
globalLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: replaceAttr}))
globalConfig = Config{Env: defaultEnv, Level: defaultLevel, Format: defaultFormat, MaxPayloadBytes: defaultMaxPayloadBytes}
)
// Init installs the process logger and redirects the standard library log package into JSON/text logs.
func Init(cfg Config) error {
normalized, err := NormalizeConfig(cfg)
if err != nil {
return err
}
level, err := parseLevel(normalized.Level)
if err != nil {
return err
}
output := normalized.Output
if output == nil {
output = os.Stdout
}
handlerOptions := &slog.HandlerOptions{Level: level, ReplaceAttr: replaceAttr}
var handler slog.Handler
switch normalized.Format {
case "json":
handler = slog.NewJSONHandler(output, handlerOptions)
case "text":
handler = slog.NewTextHandler(output, handlerOptions)
default:
return fmt.Errorf("unsupported log format %q", normalized.Format)
}
baseAttrs := make([]any, 0, 8)
baseAttrs = appendNonEmpty(baseAttrs, "env", normalized.Env)
baseAttrs = appendNonEmpty(baseAttrs, "service", normalized.Service)
baseAttrs = appendNonEmpty(baseAttrs, "version", normalized.Version)
baseAttrs = appendNonEmpty(baseAttrs, "node_id", normalized.NodeID)
logger := slog.New(handler).With(baseAttrs...)
globalMu.Lock()
globalLogger = logger
globalConfig = normalized
globalMu.Unlock()
slog.SetDefault(logger)
log.SetFlags(0)
log.SetOutput(stdLogWriter{})
return nil
}
// NormalizeConfig applies safe defaults and validates static logging knobs.
func NormalizeConfig(cfg Config) (Config, error) {
cfg.Service = strings.TrimSpace(cfg.Service)
cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env))
if cfg.Env == "" {
cfg.Env = defaultEnv
}
cfg.Version = strings.TrimSpace(cfg.Version)
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level))
if cfg.Level == "" {
cfg.Level = defaultLevel
}
cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format))
if cfg.Format == "" {
cfg.Format = defaultFormat
}
if cfg.MaxPayloadBytes <= 0 {
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
}
if _, err := parseLevel(cfg.Level); err != nil {
return Config{}, err
}
if cfg.Format != "json" && cfg.Format != "text" {
return Config{}, fmt.Errorf("unsupported log format %q", cfg.Format)
}
return cfg, nil
}
// Logger returns a logger enriched with attributes attached to ctx through With.
func Logger(ctx context.Context) *slog.Logger {
globalMu.RLock()
logger := globalLogger
globalMu.RUnlock()
attrs := attrsFromContext(ctx)
if len(attrs) == 0 {
return logger
}
args := make([]any, 0, len(attrs))
for _, attr := range attrs {
args = append(args, attr)
}
return logger.With(args...)
}
// With attaches structured fields to ctx for subsequent Logger/Info/Warn/Error calls.
func With(ctx context.Context, attrs ...slog.Attr) context.Context {
if ctx == nil {
ctx = context.Background()
}
if len(attrs) == 0 {
return ctx
}
existing := attrsFromContext(ctx)
merged := make([]slog.Attr, 0, len(existing)+len(attrs))
merged = append(merged, existing...)
merged = append(merged, attrs...)
return context.WithValue(ctx, contextAttrsKey{}, merged)
}
// Info records a normal runtime event.
func Info(ctx context.Context, msg string, attrs ...slog.Attr) {
Logger(ctx).LogAttrs(ctx, slog.LevelInfo, msg, attrs...)
}
// Warn records a recoverable runtime failure or degraded path.
func Warn(ctx context.Context, msg string, attrs ...slog.Attr) {
Logger(ctx).LogAttrs(ctx, slog.LevelWarn, msg, attrs...)
}
// Error records a request, transaction, worker, or external dependency failure.
func Error(ctx context.Context, msg string, err error, attrs ...slog.Attr) {
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
}
Logger(ctx).LogAttrs(ctx, slog.LevelError, msg, attrs...)
}
// HTTPMiddleware records gateway HTTP access logs. Health checks are intentionally excluded by callers.
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()
cfg := configSnapshot()
requestBody := readAndRestoreBody(request, cfg)
capture := newHTTPResponseCapture(writer, cfg.IncludeResponseBody, cfg.MaxPayloadBytes)
next.ServeHTTP(capture, request)
requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
ctx := With(request.Context(), slog.String("request_id", requestID), slog.String("app_code", appcode.FromContext(request.Context())))
attrs := []slog.Attr{
slog.String("method", request.Method),
slog.String("path", request.URL.Path),
slog.String("query", request.URL.RawQuery),
slog.Int("status", capture.statusCode),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
slog.String("client_ip", clientIP(request)),
}
attrs = appendServiceAttr(attrs, service)
if cfg.IncludeRequestBody {
attrs = append(attrs, slog.Any("request", safePayload(requestBody, cfg.MaxPayloadBytes, len(requestBody) > cfg.MaxPayloadBytes)))
}
if cfg.IncludeResponseBody {
attrs = append(attrs, slog.Any("response", safePayload(capture.body.Bytes(), cfg.MaxPayloadBytes, capture.truncated)))
}
switch levelForHTTPStatus(capture.statusCode) {
case slog.LevelError:
Error(ctx, "http_access", nil, attrs...)
case slog.LevelWarn:
Warn(ctx, "http_access", attrs...)
default:
Info(ctx, "http_access", attrs...)
}
})
}
// UnaryServerInterceptor records internal gRPC access logs without coupling business services to a logger.
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()
cfg := configSnapshot()
response, err := handler(ctx, request)
code := codes.OK
if err != nil {
code = status.Code(err)
}
meta := requestMetaFromPayload(request)
ctx = With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(meta.AppCode)))
attrs := []slog.Attr{
slog.String("grpc_method", info.FullMethod),
slog.String("grpc_code", code.String()),
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
}
attrs = appendServiceAttr(attrs, service)
attrs = append(attrs, commonAttrsFromPayload(request)...)
if cfg.IncludeRequestBody {
attrs = append(attrs, slog.Any("request", safePayloadFromValue(request, cfg.MaxPayloadBytes)))
}
if cfg.IncludeResponseBody {
attrs = append(attrs, slog.Any("response", safePayloadFromValue(response, cfg.MaxPayloadBytes)))
}
switch levelForGRPCCode(code) {
case slog.LevelError:
Error(ctx, "grpc_access", err, attrs...)
case slog.LevelWarn:
Warn(ctx, "grpc_access", attrs...)
default:
Info(ctx, "grpc_access", attrs...)
}
return response, err
}
}
type stdLogWriter struct{}
func (stdLogWriter) Write(data []byte) (int, error) {
line := strings.TrimSpace(string(data))
if line != "" {
Info(context.Background(), "stdlog", slog.String("message", redactLogLine(line)))
}
return len(data), nil
}
type requestMeta struct {
RequestID string
AppCode string
}
type httpResponseCapture struct {
http.ResponseWriter
statusCode int
capture bool
maxBytes int
truncated bool
body bytes.Buffer
}
func newHTTPResponseCapture(writer http.ResponseWriter, capture bool, maxBytes int) *httpResponseCapture {
return &httpResponseCapture{ResponseWriter: writer, statusCode: http.StatusOK, capture: capture, maxBytes: maxBytes}
}
func (c *httpResponseCapture) WriteHeader(statusCode int) {
c.statusCode = statusCode
c.ResponseWriter.WriteHeader(statusCode)
}
func (c *httpResponseCapture) Write(data []byte) (int, error) {
if c.capture && c.body.Len() < c.maxBytes {
remaining := c.maxBytes - c.body.Len()
if len(data) > remaining {
c.body.Write(data[:remaining])
c.truncated = true
} else {
c.body.Write(data)
}
} else if c.capture && len(data) > 0 {
c.truncated = true
}
return c.ResponseWriter.Write(data)
}
func readAndRestoreBody(request *http.Request, cfg Config) []byte {
if !cfg.IncludeRequestBody || request.Body == nil {
return nil
}
mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
if strings.HasPrefix(mediaType, "multipart/") {
return []byte("<multipart body omitted>")
}
body, err := io.ReadAll(request.Body)
if err != nil {
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, maxBytes int) any {
if value == nil {
return nil
}
raw, err := marshalPayload(value)
if err != nil {
return "<payload_marshal_failed>"
}
return safePayload(raw, maxBytes, len(raw) > maxBytes)
}
func requestMetaFromPayload(value any) requestMeta {
raw, err := marshalPayload(value)
if err != nil || len(raw) == 0 {
return requestMeta{}
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return requestMeta{}
}
result := requestMeta{RequestID: stringFromMap(payload, "request_id"), AppCode: stringFromMap(payload, "app_code")}
meta, ok := payload["meta"].(map[string]any)
if !ok {
return result
}
if result.RequestID == "" {
result.RequestID = stringFromMap(meta, "request_id")
}
if result.AppCode == "" {
result.AppCode = stringFromMap(meta, "app_code")
}
return result
}
func marshalPayload(value any) ([]byte, error) {
if value == nil {
return nil, nil
}
if message, ok := value.(proto.Message); ok {
return protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
}
return json.Marshal(value)
}
func safePayload(raw []byte, maxBytes int, truncated bool) any {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil
}
if maxBytes <= 0 {
maxBytes = defaultMaxPayloadBytes
}
if truncated || len(raw) > maxBytes {
return map[string]any{"truncated": true, "bytes": len(raw)}
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return redactLogLine(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:
redacted := make([]any, len(typed))
for index, item := range typed {
redacted[index] = redactValue(item)
}
return redacted
default:
return value
}
}
func replaceAttr(_ []string, attr slog.Attr) slog.Attr {
if isSensitiveKey(attr.Key) {
return slog.String(attr.Key, redactedValue)
}
return attr
}
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",
"receipt",
}
for _, part := range sensitiveParts {
if strings.Contains(normalized, part) {
return true
}
}
return false
}
func redactLogLine(line string) string {
fields := strings.Fields(line)
if len(fields) == 0 {
return line
}
changed := false
for index, field := range fields {
separator := strings.IndexAny(field, "=:")
if separator <= 0 {
continue
}
key := strings.Trim(field[:separator], "\"'")
if isSensitiveKey(key) {
fields[index] = field[:separator+1] + redactedValue
changed = true
}
}
if !changed {
return line
}
return strings.Join(fields, " ")
}
func attrsFromContext(ctx context.Context) []slog.Attr {
if ctx == nil {
return nil
}
attrs, _ := ctx.Value(contextAttrsKey{}).([]slog.Attr)
return attrs
}
func configSnapshot() Config {
globalMu.RLock()
cfg := globalConfig
globalMu.RUnlock()
if cfg.MaxPayloadBytes <= 0 {
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
}
return cfg
}
func parseLevel(value string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug, nil
case "", "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("%w %q", ErrUnsupportedLevel, value)
}
}
func levelForHTTPStatus(statusCode int) slog.Level {
switch {
case statusCode >= http.StatusInternalServerError:
return slog.LevelError
case statusCode >= http.StatusBadRequest:
return slog.LevelWarn
default:
return slog.LevelInfo
}
}
func levelForGRPCCode(code codes.Code) slog.Level {
switch code {
case codes.OK:
return slog.LevelInfo
case codes.Internal, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unknown:
return slog.LevelError
default:
return slog.LevelWarn
}
}
func appendServiceAttr(attrs []slog.Attr, service string) []slog.Attr {
if strings.TrimSpace(service) == "" {
return attrs
}
cfg := configSnapshot()
if cfg.Service != "" {
return attrs
}
return append(attrs, slog.String("service", service))
}
func appendNonEmpty(attrs []any, key string, value string) []any {
if strings.TrimSpace(value) == "" {
return attrs
}
return append(attrs, key, value)
}
func stringFromMap(values map[string]any, key string) string {
value, ok := values[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return typed
case fmt.Stringer:
return typed.String()
default:
return fmt.Sprint(typed)
}
}
func clientIP(request *http.Request) string {
if request == nil {
return ""
}
for _, header := range []string{"X-Forwarded-For", "X-Real-IP"} {
value := strings.TrimSpace(request.Header.Get(header))
if value == "" {
continue
}
if before, _, ok := strings.Cut(value, ","); ok {
return strings.TrimSpace(before)
}
return value
}
return request.RemoteAddr
}
func commonAttrsFromPayload(value any) []slog.Attr {
raw, err := marshalPayload(value)
if err != nil || len(raw) == 0 {
return nil
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil
}
keys := []string{
"command_id",
"user_id",
"room_id",
"room_version",
"transaction_id",
"biz_type",
"asset_type",
"sender_user_id",
"target_user_id",
"seller_user_id",
"gift_id",
"gift_count",
"event_id",
"event_type",
}
attrs := make([]slog.Attr, 0, len(keys))
for _, key := range keys {
value, ok := payload[key]
if !ok || value == nil {
continue
}
attrs = append(attrs, slog.Any(key, value))
}
return attrs
}
// ErrUnsupportedLevel is kept as a stable sentinel for configuration tests.
var ErrUnsupportedLevel = errors.New("unsupported log level")