From f227afd7e6e4a8a42e7a12537cf85d1103329b45 Mon Sep 17 00:00:00 2001 From: ucpr Date: Sat, 13 Jul 2024 00:34:18 +0900 Subject: [PATCH] chore: add custom slog handler for cloud logging --- go.mod | 8 ++- go.sum | 16 +++-- pkg/log/README.md | 1 + pkg/log/gcp_handler.go | 149 +++++++++++++++++++++++++++++++++++++++++ pkg/log/log.go | 98 ++++++++++++++------------- 5 files changed, 215 insertions(+), 57 deletions(-) create mode 100644 pkg/log/gcp_handler.go diff --git a/go.mod b/go.mod index 603744a..309258a 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,15 @@ go 1.22 toolchain go1.22.1 require ( - cloud.google.com/go/logging v1.8.1 + cloud.google.com/go/logging v1.7.0 cloud.google.com/go/pubsub v1.33.0 github.com/google/wire v0.5.0 github.com/hamba/avro/v2 v2.18.0 github.com/prometheus/client_golang v1.17.0 github.com/sethvargo/go-envconfig v1.0.1 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 go.mongodb.org/mongo-driver v1.13.0 + go.opentelemetry.io/otel/trace v1.28.0 go.uber.org/mock v0.4.0 ) @@ -21,7 +22,7 @@ require ( cloud.google.com/go/compute v1.19.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v1.1.0 // indirect - cloud.google.com/go/longrunning v0.5.0 // indirect + cloud.google.com/go/longrunning v0.4.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -49,6 +50,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/oauth2 v0.8.0 // indirect diff --git a/go.sum b/go.sum index eea398b..a73623e 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,10 @@ cloud.google.com/go/iam v1.1.0 h1:67gSqaPukx7O8WLLHMa0PNs3EBGd2eE4d+psbO/CO94= cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk= cloud.google.com/go/kms v1.11.0 h1:0LPJPKamw3xsVpkel1bDtK0vVJec3EyqdQOLitiD030= cloud.google.com/go/kms v1.11.0/go.mod h1:hwdiYC0xjnWsKQQCQQmIQnS9asjYVSK6jtXm+zFqXLM= -cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= -cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= -cloud.google.com/go/longrunning v0.5.0 h1:DK8BH0+hS+DIvc9a2TPnteUievsTCH4ORMAASSb7JcQ= -cloud.google.com/go/longrunning v0.5.0/go.mod h1:0JNuqRShmscVAhIACGtskSAWtqtOoPkwP0YF1oVEchc= +cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE= +cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= cloud.google.com/go/pubsub v1.33.0 h1:6SPCPvWav64tj0sVX/+npCBKhUi/UjJehy9op/V3p2g= cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -134,8 +134,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -149,6 +149,10 @@ go.mongodb.org/mongo-driver v1.13.0 h1:67DgFFjYOCMWdtTEmKFpV3ffWlFnh+CYZ8ZS/tXWU go.mongodb.org/mongo-driver v1.13.0/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= diff --git a/pkg/log/README.md b/pkg/log/README.md index 7a14010..993d7b5 100644 --- a/pkg/log/README.md +++ b/pkg/log/README.md @@ -9,3 +9,4 @@ Simple logging package based on log/slog. | LOG_FORMAT | Log format (`gcp`, `json`, `text`) | `text` | | | SERVICE | Service name. use for gcp log label. | | | | ENV | Environment. use for gcp log label. | | | +| GOOGLE_PROJECT_ID | Google Project Id. use for gcp logging | | | diff --git a/pkg/log/gcp_handler.go b/pkg/log/gcp_handler.go new file mode 100644 index 0000000..1f87cfd --- /dev/null +++ b/pkg/log/gcp_handler.go @@ -0,0 +1,149 @@ +package log + +import ( + "context" + "fmt" + "io" + "log/slog" + "runtime" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/trace" +) + +const ( + cloudLoggingSourceKey = "logging.googleapis.com/sourceLocation" + cloudLoggingLabelsKey = "logging.googleapis.com/labels" + cloudLoggingTraceKey = "logging.googleapis.com/trace" + cloudLoggingTraceSpanKey = "logging.googleapis.com/spanId" + cloudLoggingTraceSampledKey = "logging.googleapis.com/trace_sampled" + + cloudLoggingMessageKey = "message" + cloudLoggingSeverityKey = "severity" + cloudLoggingEnvironmentLabel = "env" + cloudLoggingServiceLabel = "app" + cloudLoggingTraceFormat = "projects/%s/traces/%s" +) + +type CloudLoggingHandler struct { + handler slog.Handler + environment string + service string + projectID string +} + +type CloudLoggingHandlerOptions struct { + AddSource bool + Level slog.Level + Environment string + Service string + ProjectID string + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +// Ensure CloudLoggingHandler implements slog.Handler. +var _ slog.Handler = (*CloudLoggingHandler)(nil) + +// NewCloudLoggingHandler creates a new CloudLoggingHandler. +func NewCloudLoggingHandler(out io.Writer, options *CloudLoggingHandlerOptions) *CloudLoggingHandler { + replaceAttr := func(groups []string, attr slog.Attr) slog.Attr { + cattr := attrReplacerForCloudLogging(groups, attr) + if options.ReplaceAttr != nil { + cattr = options.ReplaceAttr(groups, cattr) + } + return cattr + } + + handler := slog.NewJSONHandler(out, &slog.HandlerOptions{ + AddSource: options.AddSource, + Level: options.Level, + ReplaceAttr: replaceAttr, + }) + + return &CloudLoggingHandler{ + handler: handler, + environment: options.Environment, + service: options.Service, + projectID: options.ProjectID, + } +} + +// Enabled implements the slog.Handler interface. +func (h *CloudLoggingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +// Handle implements the slog.Handler interface. +func (h *CloudLoggingHandler) Handle(ctx context.Context, record slog.Record) error { + record.AddAttrs( + slog.Group(cloudLoggingLabelsKey, + slog.String(cloudLoggingServiceLabel, h.service), + slog.String(cloudLoggingEnvironmentLabel, h.environment), + ), + ) + record.Level = h.toCloudLoggingLevel(record.Level) + + // set trace + sc := trace.SpanContextFromContext(ctx) + if sc.IsValid() { + trace := fmt.Sprintf(cloudLoggingTraceFormat, h.projectID, sc.TraceID().String()) + record.AddAttrs( + slog.String(cloudLoggingTraceKey, trace), + slog.String(cloudLoggingTraceSpanKey, sc.SpanID().String()), + slog.Bool(cloudLoggingTraceSampledKey, sc.TraceFlags().IsSampled()), + ) + } + + return h.handler.Handle(ctx, record) +} + +// WithAttrs implements the slog.Handler interface. +func (h *CloudLoggingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h.handler.WithAttrs(attrs) +} + +// WithGroup implements the slog.Handler interface. +func (h *CloudLoggingHandler) WithGroup(name string) slog.Handler { + return h.handler.WithGroup(name) +} + +func (h *CloudLoggingHandler) toCloudLoggingLevel(level slog.Level) slog.Level { + switch level { + case SeverityDebug: + return slog.Level(logging.Debug) + case SeverityInfo: + return slog.Level(logging.Info) + case SeverityNotice: + return slog.Level(logging.Notice) + case SeverityWarning: + return slog.Level(logging.Warning) + case SeverityError: + return slog.Level(logging.Error) + case SeverityCritical: + return slog.Level(logging.Critical) + } + return slog.Level(logging.Info) +} + +// attrReplacerForCloudLogging is default attribute replacer. +func attrReplacerForCloudLogging(_ []string, attr slog.Attr) slog.Attr { + switch attr.Key { + case slog.MessageKey: + attr.Key = cloudLoggingMessageKey + case slog.LevelKey: + attr.Key = cloudLoggingSeverityKey + attr.Value = slog.StringValue(logging.Severity(attr.Value.Any().(slog.Level)).String()) + case slog.SourceKey: + attr.Key = cloudLoggingSourceKey + // Replace the value of the "source" attribute with the value of the "sourceLocation" attribute. + const skip = 9 + _, file, line, ok := runtime.Caller(skip) + if !ok { + return attr + } + v := fmt.Sprintf("%s:%d", file, line) + attr.Value = slog.StringValue(v) + } + + return attr +} diff --git a/pkg/log/log.go b/pkg/log/log.go index 4e2b470..723e305 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -6,91 +6,93 @@ import ( "log/slog" "os" "runtime" - - "cloud.google.com/go/logging" + "strings" ) //nolint:gochecknoglobals var ( - SeverityDefault = slog.Level(logging.Default) - SeverityDebug = slog.Level(logging.Debug) - SeverityInfo = slog.Level(logging.Info) - SeverityNotice = slog.Level(logging.Notice) - SeverityWarning = slog.Level(logging.Warning) - SeverityError = slog.Level(logging.Error) - SeverityCritical = slog.Level(logging.Critical) + SeverityDebug = slog.LevelDebug // -4 + SeverityInfo = slog.LevelInfo // 0 + SeverityNotice = slog.Level(2) // 2 + SeverityWarning = slog.LevelWarn // 4 + SeverityError = slog.LevelError // 8 + SeverityCritical = slog.Level(10) // 10 +) + +// format is the log format type +type format string + +//nolint:unused +const ( + // formatGCP is the Google Cloud Platform format. + formatGCP format = "gcp" + // formatJSON is the JSON format. + formatJSON format = "json" + // formatText is the text format. + formatText format = "text" ) // logger is the global logger. // it is initialized by init() and should not be modified. +// +//nolint:gochecknoglobals var logger *slog.Logger // init initializes the logger. func init() { - logFormat := os.Getenv("LOG_FORMAT") + logFormat := strings.ToLower(os.Getenv("LOG_FORMAT")) service := os.Getenv("SERVICE") env := os.Getenv("ENV") + googleProjectID := os.Getenv("GOOGLE_PROJECT_ID") - handler := newHandler(logFormat, service, env) + handler := newHandler(newHandlerOptions{ + format: format(logFormat), + service: service, + env: env, + googleProjectID: googleProjectID, + }) logger = slog.New(handler) } +type newHandlerOptions struct { + format format + service string + env string + googleProjectID string +} + // newHandler returns a slog.Handler based on the given format. -func newHandler(format, service, env string) slog.Handler { - switch format { - case "gcp": - handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ +func newHandler(opts newHandlerOptions) slog.Handler { + switch opts.format { + case formatGCP: + return NewCloudLoggingHandler(os.Stdout, &CloudLoggingHandlerOptions{ AddSource: true, - Level: SeverityDefault, - ReplaceAttr: attrReplacerForCloudLogging, + Level: SeverityInfo, + Environment: opts.env, + Service: opts.service, + ProjectID: opts.googleProjectID, }) - handler.WithAttrs([]slog.Attr{ - slog.Group("logging.googleapis.com/labels", - slog.String("app", service), - slog.String("env", env), - ), - }) - return handler - case "json": + case formatJSON: return slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: SeverityDefault, + Level: SeverityInfo, ReplaceAttr: attrReplacerForDefault, }) } return slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: SeverityDefault, + Level: SeverityInfo, ReplaceAttr: attrReplacerForDefault, }) } // attrReplacerForDefault is default attribute replacer. func attrReplacerForDefault(groups []string, attr slog.Attr) slog.Attr { - // Replace the value of the "severity" attribute with the value of the "level" attribute. - level, ok := attr.Value.Any().(slog.Level) - if ok { - attr.Value = toLogLevel(level) - } - return attr -} - -// attrReplacerForCloudLogging replaces slog's default attributes for Google Cloud Logging. -func attrReplacerForCloudLogging(groups []string, attr slog.Attr) slog.Attr { switch attr.Key { - case slog.MessageKey: - attr.Key = "message" case slog.LevelKey: - attr.Key = "severity" - attr.Value = slog.StringValue(logging.Severity(attr.Value.Any().(slog.Level)).String()) - // Replace the value of the "severity" attribute with the value of the "level" attribute. - level, ok := attr.Value.Any().(slog.Level) - if ok { - attr.Value = toLogLevel(level) - } + attr.Value = toLogLevel(attr.Value.Any().(slog.Level)) case slog.SourceKey: - attr.Key = "logging.googleapis.com/sourceLocation" // Replace the value of the "source" attribute with the value of the "sourceLocation" attribute. - const skip = 7 + const skip = 9 _, file, line, ok := runtime.Caller(skip) if !ok { return attr