634 lines
17 KiB
Go
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")
|