Files
zitadel/backend/v3/instrumentation/slog.go
T
c02d812b6c fix(slog): masking of grouped attributes (#11606)
# Which Problems Are Solved

Group attributes weren't masked if their corresponding key was
configured. For example setting `data` as a masked key entry in the
runtime config would still print unmasked event data. This was because
ReplaceAttr does not receive group attributes, only the flattened
attributes with group information.

# How the Problems Are Solved

Check the current groups stack in the replacer. When a configured key is
found in the group, mask the current attribute. This means that the
attribute structure is preserved and all sub-keys of a masked group are
still printed.

# Additional Changes

- Use a binary search for key matching. As we are now comparing slice
against slice, a binary search should make the process a little faster
if many keys are configured.

# Additional Context

- Follow-up for PR #11435

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
2026-02-18 06:00:13 +00:00

246 lines
6.3 KiB
Go

package instrumentation
import (
"context"
"log/slog"
"os"
"slices"
"sync/atomic"
"time"
"github.com/rs/xid"
slogmulti "github.com/samber/slog-multi"
slogctx "github.com/veqryn/slog-context"
slogotel "github.com/veqryn/slog-context/otel"
old_logging "github.com/zitadel/logging" //nolint:staticcheck
"github.com/zitadel/sloggcp"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel/sdk/log"
"github.com/zitadel/zitadel/internal/zerrors"
)
type LogFormat int
//go:generate enumer -type=LogFormat -trimprefix=LogFormat -text -linecomment -transform=snake
const (
// Empty line comment sets empty string of unspecified value
LogFormatUnspecified LogFormat = iota //
LogFormatDisabled
LogFormatText
LogFormatJSON
// JSON formatted logs compatible with Google Cloud Platform
LogFormatGCP
LogFormatGCPErrorReporting
)
func (f LogFormat) isDisabled() bool {
return f == LogFormatUnspecified || f == LogFormatDisabled
}
type LogConfig struct {
Level slog.Level
Streams []Stream
Mask MaskConfig
Format LogFormat
AddSource bool
Errors ErrorConfig
Exporter ExporterConfig
}
func (c *LogConfig) SetLegacyConfig(lc *old_logging.Config) {
if lc == nil || !c.Format.isDisabled() {
return
}
err := c.Level.UnmarshalText([]byte(lc.Level))
if err != nil {
c.Level = slog.LevelInfo
}
c.Format, err = LogFormatString(lc.Formatter.Format)
if err != nil {
c.Format = LogFormatText
}
c.AddSource = lc.AddSource
}
func (c LogConfig) replacer() replacer {
var replacers []replacer
if len(c.Mask.Keys) > 0 {
replacers = append(replacers, c.Mask.replacer())
}
if c.Format == LogFormatGCP {
replacers = append(replacers, sloggcp.ReplaceAttr)
}
if c.Format == LogFormatGCPErrorReporting {
replacers = append(replacers, errReplacer())
}
return chainReplacers(replacers...)
}
type MaskConfig struct {
Keys []string
Value string
}
// replacer returns a function that replaces the value of any attribute whose key matches
// one of the configured keys with the configured value.
// If a group key matches, all attribute values in that group are replaced.
// Attribute structure is preserved, e.g. "password" is replaced with "masked" but still appears as "password" in the logs.
func (c MaskConfig) replacer() replacer {
keys := slices.Clone(c.Keys)
slices.Sort(keys)
return func(groups []string, a slog.Attr) slog.Attr {
// mask all entries in a matching group, e.g. "data.*"
for _, g := range groups {
if _, found := slices.BinarySearch(keys, g); found {
a.Value = slog.StringValue(c.Value)
return a
}
}
if _, found := slices.BinarySearch(keys, a.Key); found {
a.Value = slog.StringValue(c.Value)
}
return a
}
}
type ErrorConfig struct {
ReportLocation bool
StackTrace bool
}
var addSourceEnabled atomic.Bool
func IsAddSourceEnabled() bool {
return addSourceEnabled.Load()
}
// setLogger configures the global slog logger.
// Logs are sent to [os.Stderr] and/or the [log.LoggerProvider].
//
// When present in the context, each line emitted contains:
// - Trace ID
// - Span ID
// - Service name
// - Sanitized request hosts from [http_util.DomainCtx]
// - Request details, such as method and path.
func setLogger(provider *log.LoggerProvider, cfg LogConfig) {
options := &slog.HandlerOptions{
AddSource: cfg.AddSource,
Level: cfg.Level,
ReplaceAttr: cfg.replacer(),
}
addSourceEnabled.Store(cfg.AddSource)
var stdErrHandler slog.Handler
switch cfg.Format {
case LogFormatUnspecified:
return // uses default slog logger
case LogFormatDisabled:
stdErrHandler = slog.DiscardHandler
case LogFormatText:
stdErrHandler = slog.NewTextHandler(os.Stderr, options)
case LogFormatJSON:
stdErrHandler = slog.NewJSONHandler(os.Stderr, options)
case LogFormatGCP:
stdErrHandler = slog.NewJSONHandler(os.Stderr, options)
case LogFormatGCPErrorReporting:
zerrors.GCPErrorReportingEnabled(true)
stdErrHandler = sloggcp.NewErrorReportingHandler(os.Stderr, options)
}
EnableStreams(cfg.Streams...)
zerrors.EnableReportLocation(cfg.Errors.ReportLocation)
zerrors.EnableStackTrace(cfg.Errors.StackTrace)
stdErrHandler = slogctx.NewHandler(
stdErrHandler,
&slogctx.HandlerOptions{
Prependers: []slogctx.AttrExtractor{
instanceExtractor,
requestIDExtractor,
slogotel.ExtractTraceSpanID,
slogctx.ExtractPrepended,
},
},
)
otelHandler := otelslog.NewHandler(
Name,
otelslog.WithLoggerProvider(provider),
)
logger := slog.New(slogmulti.Fanout(stdErrHandler, otelHandler))
logger.Info("structured logger configured", "config_level", cfg.Level, "format", cfg.Format)
slog.SetDefault(logger)
}
const (
ProtocolHttp = "http"
ProtocolConnect = "connect"
ProtocolGrpc = "grpc"
)
// Instance is a minimal interface for logging the instance ID.
type Instance interface {
InstanceID() string
}
// SetInstance adds the instance to the context for logging.
func SetInstance(ctx context.Context, instance Instance) context.Context {
return context.WithValue(ctx, ctxKeyInstance, instance)
}
// SetRequest generates a new [xid.ID] based on the passed request timestamp
// and adds it to the context.
func SetRequestID(ctx context.Context, ts time.Time) context.Context {
return context.WithValue(ctx, ctxKeyRequestID, xid.NewWithTime(ts))
}
type ctxKeyType int
const (
ctxKeyRequestID ctxKeyType = iota
ctxKeyInstance
)
// instanceExtractor sets the instance ID from [Instance] to a log entry.
func instanceExtractor(ctx context.Context, _ time.Time, _ slog.Level, _ string) []slog.Attr {
if instance, ok := ctx.Value(ctxKeyInstance).(Instance); ok {
return []slog.Attr{
slog.String("instance", instance.InstanceID()),
}
}
return nil
}
// requestIDExtractor sets the request XID to a log entry.
func requestIDExtractor(ctx context.Context, _ time.Time, _ slog.Level, _ string) []slog.Attr {
if r, ok := ctx.Value(ctxKeyRequestID).(xid.ID); ok {
return []slog.Attr{
slog.String("request_id", r.String()),
}
}
return nil
}
type replacer func(groups []string, a slog.Attr) slog.Attr
func chainReplacers(replacers ...replacer) replacer {
return func(groups []string, a slog.Attr) slog.Attr {
for _, r := range replacers {
a = r(groups, a)
}
return a
}
}
func errReplacer() replacer {
return func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == "err" {
a.Key = sloggcp.ErrorKey
}
return a
}
}