diff --git a/README.md b/README.md index 7c84510..69700ef 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,9 @@ The OpenTelemetry exporter can be configured using the following settings in the ```hcl telemetry { logging { - type = "otel" # Use OpenTelemetry + otel { + enabled = true # Enable OpenTelemetry logging + } } metrics { enabled = true # Enable metrics collection @@ -314,15 +316,19 @@ The OTLP exporter is configured using the common OpenTelemetry environment varia ### slog logging -To use `log/slog` for logging, you can configure the telemetry logging settings in your NACP configuration file. This allows you to set the logging type, level, and handler. +To use `log/slog` for logging, you can configure the telemetry logging settings in your NACP configuration file. This allows you to add json and text slog handlers. ```hcl telemetry { logging { - type = "slog" # Use slog for logging + level = "info" # Set the logging level (e.g., debug, info, warn, error) slog { - handler = "text" # Set the slog handler (e.g., text, json) + json = true # Adds the json slog handler (defaults to false) + text = true # Adds the text slog handler (defaults to false) + + text_out = "stderr" # default "stdout" + json_out = "stdout" # same } } } diff --git a/cmd/nacp/nacp.go b/cmd/nacp/nacp.go index 8db8cb9..69a922e 100644 --- a/cmd/nacp/nacp.go +++ b/cmd/nacp/nacp.go @@ -24,6 +24,7 @@ import ( "github.com/mxab/nacp/admissionctrl/remoteutil" "github.com/mxab/nacp/admissionctrl/types" + "github.com/mxab/nacp/logutil" nacpOtel "github.com/mxab/nacp/otel" "log/slog" @@ -39,7 +40,6 @@ import ( "github.com/notaryproject/notation-go/dir" "github.com/notaryproject/notation-go/verifier/truststore" - "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -111,15 +111,15 @@ func resolveTokenAccessor(ctx context.Context, transport http.RoundTripper, noma return &aclToken, nil } -func NewProxyAsHandlerFunc(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler, appLogger *slog.Logger, transport http.RoundTripper) http.HandlerFunc { +func NewProxyAsHandlerFunc(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler, logger *slog.Logger, transport http.RoundTripper) http.HandlerFunc { - proxy := newProxyHandler(nomadAddress, jobHandler, appLogger, transport) + proxy := newProxyHandler(nomadAddress, jobHandler, logger, transport) handlerFunc := http.HandlerFunc(proxy) handlerFunc = otelhttp.NewHandler(handlerFunc, "/").(http.HandlerFunc) return handlerFunc } -func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler, appLogger *slog.Logger, transport http.RoundTripper) func(http.ResponseWriter, *http.Request) { +func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler, logger *slog.Logger, transport http.RoundTripper) func(http.ResponseWriter, *http.Request) { proxy := httputil.NewSingleHostReverseProxy(nomadAddress) @@ -138,14 +138,14 @@ func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler var err error if isRegister(resp.Request) { - err = handRegisterResponse(resp, appLogger) + err = handRegisterResponse(resp, logger) } else if isPlan(resp.Request) { - err = handleJobPlanResponse(resp, appLogger) + err = handleJobPlanResponse(resp, logger) } else if isValidate(resp.Request) { - err = handleJobValdidateResponse(resp, appLogger) + err = handleJobValdidateResponse(resp, logger) } if err != nil { - appLogger.ErrorContext(resp.Request.Context(), "Preparing response failed", "error", err) + logger.ErrorContext(resp.Request.Context(), "Preparing response failed", "error", err) return err } @@ -166,16 +166,16 @@ func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler if jobHandler.ResolveToken() { tokenInfo, err := resolveTokenAccessor(ctx, transport, nomadAddress, token) if err != nil { - appLogger.ErrorContext(ctx, "Resolving token failed", "error", err) + logger.ErrorContext(ctx, "Resolving token failed", "error", err) writeError(w, err) } if tokenInfo != nil { reqCtx.AccessorID = tokenInfo.AccessorID reqCtx.TokenInfo = tokenInfo } - appLogger.InfoContext(ctx, "Request received", "path", r.URL.Path, "method", r.Method, "clientIP", reqCtx.ClientIP, "accessorID", reqCtx.AccessorID) + logger.InfoContext(ctx, "Request received", "path", r.URL.Path, "method", r.Method, "clientIP", reqCtx.ClientIP, "accessorID", reqCtx.AccessorID) } else { - appLogger.InfoContext(ctx, "Request received", "path", r.URL.Path, "method", r.Method, "clientIP", reqCtx.ClientIP) + logger.InfoContext(ctx, "Request received", "path", r.URL.Path, "method", r.Method, "clientIP", reqCtx.ClientIP) } ctx = context.WithValue(ctx, "request_context", reqCtx) @@ -183,17 +183,17 @@ func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler var err error if isRegister(r) { - r, err = handleRegister(r, appLogger, jobHandler) + r, err = handleRegister(r, logger, jobHandler) } else if isPlan(r) { - r, err = handlePlan(r, appLogger, jobHandler) + r, err = handlePlan(r, logger, jobHandler) } else if isValidate(r) { - r, err = handleValidate(r, appLogger, jobHandler) + r, err = handleValidate(r, logger, jobHandler) } if err != nil { - appLogger.WarnContext(ctx, "Error applying admission controllers", "error", err) + logger.WarnContext(ctx, "Error applying admission controllers", "error", err) writeError(w, err) } else { @@ -205,7 +205,7 @@ func newProxyHandler(nomadAddress *url.URL, jobHandler *admissionctrl.JobHandler } -func handRegisterResponse(resp *http.Response, applogger *slog.Logger) error { +func handRegisterResponse(resp *http.Response, logger *slog.Logger) error { warnings, ok := resp.Request.Context().Value(ctxWarnings).([]error) if !ok && len(warnings) == 0 { @@ -254,7 +254,7 @@ func checkIfGzipAndTransformReader(resp *http.Response, reader io.ReadCloser) (b } return isGzip, reader, nil } -func handleJobPlanResponse(resp *http.Response, applogger *slog.Logger) error { +func handleJobPlanResponse(resp *http.Response, logger *slog.Logger) error { warnings, ok := resp.Request.Context().Value(ctxWarnings).([]error) if !ok && len(warnings) == 0 { return nil @@ -286,7 +286,7 @@ func handleJobPlanResponse(resp *http.Response, applogger *slog.Logger) error { } return nil } -func handleJobValdidateResponse(resp *http.Response, appLogger *slog.Logger) error { +func handleJobValdidateResponse(resp *http.Response, logger *slog.Logger) error { ctx := resp.Request.Context() validationErr, okErr := ctx.Value(ctxValidationError).(error) @@ -528,20 +528,26 @@ func isValidate(r *http.Request) bool { return (r.Method == "PUT" || r.Method == "POST") && r.URL.Path == "/v1/validate/job" } +func buildSlogHandler(json bool, level slog.Level) slog.Handler { + opts := &slog.HandlerOptions{ + Level: level, + } + if json { + return slog.NewJSONHandler(os.Stdout, opts) + } + return slog.NewTextHandler(os.Stdout, opts) +} + // https://www.codedodle.com/go-reverse-proxy-example.html // https://joshsoftware.wordpress.com/2021/05/25/simple-and-powerful-reverseproxy-in-go/ + func main() { configPtr := flag.String("config", "", "point to a nacp config file") bootstrapLoggerHandlerPtr := flag.Bool("bootstrap-json-logger", false, "use json for initial logging until config is loaded") flag.Parse() - var bootstrapLogger *slog.Logger - if *bootstrapLoggerHandlerPtr { - bootstrapLogger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) - } else { - bootstrapLogger = slog.New(slog.NewTextHandler(os.Stdout, nil)) - } - slog.SetDefault(bootstrapLogger) + + slog.SetDefault(slog.New(buildSlogHandler(*bootstrapLoggerHandlerPtr, slog.LevelInfo))) c, err := buildConfig(*configPtr) if err != nil { @@ -561,18 +567,15 @@ func run(c *config.Config) (err error) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - loggerFactory, err := buildLoggerFactory(c) - if err != nil { - return fmt.Errorf("failed to build logger factory: %w", err) - } + rootFactory, leveler := logutil.NewLoggerFactoryFromConfig(c.Telemetry.Logging) + + appLogger := rootFactory.GetLogger("nacp") + slog.SetDefault(appLogger) - logger := loggerFactory("nacp") - slog.SetDefault(logger) - appLogger := logger - setupOtel := c.Telemetry.Logging.IsOtel() || c.Telemetry.Metrics.Enabled || c.Telemetry.Tracing.Enabled + setupOtel := *c.Telemetry.Logging.OtelLogging.Enabled || c.Telemetry.Metrics.Enabled || c.Telemetry.Tracing.Enabled if setupOtel { // Set up OpenTelemetry. - otelShutdown, err := nacpOtel.SetupOTelSDK(ctx, c.Telemetry.Logging.IsOtel(), c.Telemetry.Metrics.Enabled, c.Telemetry.Tracing.Enabled, version) + otelShutdown, err := nacpOtel.SetupOTelSDK(ctx, *c.Telemetry.Logging.OtelLogging.Enabled, c.Telemetry.Metrics.Enabled, c.Telemetry.Tracing.Enabled, version, leveler.GetSeverietier()) if err != nil { return fmt.Errorf("failed to setup OpenTelemetry: %w", err) } @@ -580,18 +583,11 @@ func run(c *config.Config) (err error) { // https://opentelemetry.io/docs/languages/go/getting-started/ defer func() { err = errors.Join(err, otelShutdown(context.Background())) - }() } - level := slog.Level(0) - if err := level.UnmarshalText([]byte(c.Telemetry.Logging.Level)); err != nil { - return fmt.Errorf("failed to parse log level: %w", err) - } - slog.SetLogLoggerLevel(level) - - server, err := buildServer(c, loggerFactory) + server, err := buildServer(c, rootFactory) if err != nil { return fmt.Errorf("failed to build server: %w", err) @@ -627,34 +623,7 @@ func run(c *config.Config) (err error) { } -func buildLoggerFactory(c *config.Config) (lf loggerFactory, err error) { - if c.Telemetry.Logging.IsSlog() { - if c.Telemetry.Logging.SlogLogging.Handler == "json" { - lf = func(_ string) *slog.Logger { - return slog.New(slog.NewJSONHandler(os.Stdout, nil)) - } - - } else if c.Telemetry.Logging.SlogLogging.Handler == "text" { - lf = func(_ string) *slog.Logger { - return slog.New(slog.NewTextHandler(os.Stdout, nil)) - } - - } else { - return nil, fmt.Errorf("invalid slog logging handler, only json and text are supported") - } - - } else if c.Telemetry.Logging.IsOtel() { - lf = func(name string) *slog.Logger { - return otelslog.NewLogger(name) - } - - } else { - return nil, fmt.Errorf("invalid logging type, only slog and otel are supported") - } - return -} - -func buildServer(c *config.Config, loggerFactory loggerFactory) (*http.Server, error) { +func buildServer(c *config.Config, loggerFactory *logutil.LoggerFactory) (*http.Server, error) { backend, err := url.Parse(c.Nomad.Address) if err != nil { return nil, fmt.Errorf("failed to parse nomad address: %w", err) @@ -696,11 +665,11 @@ func buildServer(c *config.Config, loggerFactory loggerFactory) (*http.Server, e jobMutators, jobValidators, - loggerFactory("handler"), + loggerFactory.GetLogger("handler"), resolveToken, ) - handlerFunc := NewProxyAsHandlerFunc(backend, jobHandler, loggerFactory("proxy-handler"), instrumentedProxyTransport) + handlerFunc := NewProxyAsHandlerFunc(backend, jobHandler, loggerFactory.GetLogger("proxy-handler"), instrumentedProxyTransport) bind := fmt.Sprintf("%s:%d", c.Bind, c.Port) var tlsConfig *tls.Config @@ -760,7 +729,7 @@ func createTlsConfig(caFile string, noClientCert bool) (*tls.Config, error) { return tlsConfig, nil } -func createMutators(c *config.Config, loggerFactory loggerFactory) ([]admissionctrl.JobMutator, bool, error) { +func createMutators(c *config.Config, loggerFactory *logutil.LoggerFactory) ([]admissionctrl.JobMutator, bool, error) { var jobMutators []admissionctrl.JobMutator var resolveToken bool for _, m := range c.Mutators { @@ -769,18 +738,18 @@ func createMutators(c *config.Config, loggerFactory loggerFactory) ([]admissionc } switch m.Type { case "opa_json_patch": - notationVerifier, err := buildVerifierIfEnabled(m.OpaRule.Notation, loggerFactory("notation_verifier")) + notationVerifier, err := buildVerifierIfEnabled(m.OpaRule.Notation, loggerFactory.GetLogger("notation_verifier")) if err != nil { return nil, resolveToken, err } - mutator, err := mutator.NewOpaJsonPatchMutator(m.Name, m.OpaRule.Filename, m.OpaRule.Query, loggerFactory("opa_mutator"), notationVerifier) + mutator, err := mutator.NewOpaJsonPatchMutator(m.Name, m.OpaRule.Filename, m.OpaRule.Query, loggerFactory.GetLogger("opa_mutator"), notationVerifier) if err != nil { return nil, resolveToken, err } jobMutators = append(jobMutators, mutator) case "json_patch_webhook": - mutator, err := mutator.NewJsonPatchWebhookMutator(m.Name, m.Webhook.Endpoint, m.Webhook.Method, loggerFactory("json_patch_webhook_mutator")) + mutator, err := mutator.NewJsonPatchWebhookMutator(m.Name, m.Webhook.Endpoint, m.Webhook.Method, loggerFactory.GetLogger("json_patch_webhook_mutator")) if err != nil { return nil, resolveToken, err } @@ -793,7 +762,7 @@ func createMutators(c *config.Config, loggerFactory loggerFactory) ([]admissionc } return jobMutators, resolveToken, nil } -func createValidators(c *config.Config, loggerFactory loggerFactory) ([]admissionctrl.JobValidator, bool, error) { +func createValidators(c *config.Config, loggerFactory *logutil.LoggerFactory) ([]admissionctrl.JobValidator, bool, error) { var jobValidators []admissionctrl.JobValidator var resolveToken bool for _, v := range c.Validators { @@ -802,28 +771,28 @@ func createValidators(c *config.Config, loggerFactory loggerFactory) ([]admissio } switch v.Type { case "opa": - notationVerifier, err := buildVerifierIfEnabled(v.Notation, loggerFactory("notation_verifier")) + notationVerifier, err := buildVerifierIfEnabled(v.Notation, loggerFactory.GetLogger("notation_verifier")) if err != nil { return nil, resolveToken, err } - opaValidator, err := validator.NewOpaValidator(v.Name, v.OpaRule.Filename, v.OpaRule.Query, loggerFactory("opa_validator"), notationVerifier) + opaValidator, err := validator.NewOpaValidator(v.Name, v.OpaRule.Filename, v.OpaRule.Query, loggerFactory.GetLogger("opa_validator"), notationVerifier) if err != nil { return nil, resolveToken, err } jobValidators = append(jobValidators, opaValidator) case "webhook": - validator, err := validator.NewWebhookValidator(v.Name, v.Webhook.Endpoint, v.Webhook.Method, loggerFactory("webhook_validator")) + validator, err := validator.NewWebhookValidator(v.Name, v.Webhook.Endpoint, v.Webhook.Method, loggerFactory.GetLogger("webhook_validator")) if err != nil { return nil, resolveToken, err } jobValidators = append(jobValidators, validator) case "notation": - notationVerifier, err := buildVerifier(v.Notation, loggerFactory("notation_verifier")) + notationVerifier, err := buildVerifier(v.Notation, loggerFactory.GetLogger("notation_verifier")) if err != nil { return nil, resolveToken, err } - validator := validator.NewNotationValidator(loggerFactory("notation_validator"), v.Name, notationVerifier) + validator := validator.NewNotationValidator(loggerFactory.GetLogger("notation_validator"), v.Name, notationVerifier) jobValidators = append(jobValidators, validator) diff --git a/cmd/nacp/nacp_test.go b/cmd/nacp/nacp_test.go index 4c2a6e0..67651ec 100644 --- a/cmd/nacp/nacp_test.go +++ b/cmd/nacp/nacp_test.go @@ -28,6 +28,7 @@ import ( "github.com/mxab/nacp/admissionctrl/mutator" "github.com/mxab/nacp/admissionctrl/validator" "github.com/mxab/nacp/config" + "github.com/mxab/nacp/logutil" "github.com/mxab/nacp/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -597,11 +598,9 @@ func sendPut(t *testing.T, url string, body io.Reader) (*http.Response, error) { return http.DefaultClient.Do(req) } -func discardFactory(name string) *slog.Logger { - return slog.New(slog.DiscardHandler) -} func TestDefaultBuildServer(t *testing.T) { + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) c, err := buildConfig("") require.NoError(t, err) server, err := buildServer(c, discardFactory) @@ -611,6 +610,8 @@ func TestDefaultBuildServer(t *testing.T) { } func TestBuildServerFailsOnInvalidNomadUrl(t *testing.T) { + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) + c := config.DefaultConfig() c.Nomad.Address = ":localhost:4646" _, err := buildServer(c, discardFactory) @@ -618,6 +619,8 @@ func TestBuildServerFailsOnInvalidNomadUrl(t *testing.T) { } func TestBuildServerFailsInvalidValidatorTypes(t *testing.T) { + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) + c := config.DefaultConfig() c.Validators = append(c.Validators, config.Validator{ Type: "doesnotexit", @@ -626,6 +629,8 @@ func TestBuildServerFailsInvalidValidatorTypes(t *testing.T) { assert.Error(t, err, "failed to create validators: unknown validator type doesnotexit") } func TestBuildServerFailsInvalidMutatorTypes(t *testing.T) { + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) + c := config.DefaultConfig() c.Mutators = append(c.Mutators, config.Mutator{ Type: "doesnotexit", @@ -685,6 +690,9 @@ func TestCreateValidators(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { + + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) + c := &config.Config{ Validators: []config.Validator{tc.validators}, } @@ -703,6 +711,7 @@ func TestCreateValidators(t *testing.T) { } } func TestNotationValidatorConfig(t *testing.T) { + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) policyDir := t.TempDir() @@ -804,6 +813,9 @@ func TestCreateMutatators(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { + + discardFactory, _ := logutil.NewLoggerFactory(nil, nil, false) + c := &config.Config{ Mutators: []config.Mutator{tc.mutators}, } @@ -949,7 +961,7 @@ func TestRunTerminatesOnSIGINT(t *testing.T) { config: func() *config.Config { cfg := config.DefaultConfig() cfg.Port = freePort(t) - cfg.Telemetry.Logging.Type = "otel" + cfg.Telemetry.Logging.OtelLogging.Enabled = config.Ptr(true) cfg.Telemetry.Metrics.Enabled = true cfg.Telemetry.Tracing.Enabled = true return cfg diff --git a/config/config.go b/config/config.go index c7fa016..475fb47 100644 --- a/config/config.go +++ b/config/config.go @@ -1,11 +1,18 @@ package config import ( + "fmt" + "slices" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsimple" "github.com/hashicorp/nomad/api" ) +func Ptr[T any](v T) *T { + return &v +} + type Webhook struct { Endpoint string `hcl:"endpoint"` Method string `hcl:"method"` @@ -65,19 +72,19 @@ type NotationVerifierConfig struct { } type SlogLogging struct { - Handler string `hcl:"handler,optional"` // "json" or "text" + Text *bool `hcl:"text,optional"` + TextOut *string `hcl:"text_out,optional"` + + Json *bool `hcl:"json,optional"` + JsonOut *string `hcl:"json_out,optional"` +} +type OtelLogging struct { + Enabled *bool `hcl:"enabled,optional"` } type Logging struct { Level string `hcl:"level,optional"` - Type string `hcl:"type,optional"` // "slog" or "otel" SlogLogging *SlogLogging `hcl:"slog,block"` -} - -func (l *Logging) IsOtel() bool { - return l.Type == "otel" -} -func (l *Logging) IsSlog() bool { - return l.Type == "slog" + OtelLogging *OtelLogging `hcl:"otel,block"` } type Metrics struct { @@ -119,9 +126,15 @@ func DefaultConfig() *Config { Telemetry: &Telemetry{ Logging: &Logging{ Level: "info", - Type: "slog", + SlogLogging: &SlogLogging{ - Handler: "text", + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), }, }, Metrics: &Metrics{ @@ -152,5 +165,13 @@ func LoadConfig(name string) (*Config, error) { } } + // verify json/text out + var validOuts = []string{"stdout", "stderr"} + if !slices.Contains(validOuts, *c.Telemetry.Logging.SlogLogging.TextOut) { + return nil, fmt.Errorf("invalid slog text output: %s", *c.Telemetry.Logging.SlogLogging.TextOut) + } + if !slices.Contains(validOuts, *c.Telemetry.Logging.SlogLogging.JsonOut) { + return nil, fmt.Errorf("invalid slog json output: %s", *c.Telemetry.Logging.SlogLogging.JsonOut) + } return c, nil } diff --git a/config/config_test.go b/config/config_test.go index d2fec52..7cb8be3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -8,6 +10,7 @@ import ( ) func TestLoadConfig(t *testing.T) { + type args struct { name string } @@ -35,9 +38,14 @@ func TestLoadConfig(t *testing.T) { Telemetry: &Telemetry{ Logging: &Logging{ Level: "info", - Type: "slog", - SlogLogging: &SlogLogging{ //just default part - Handler: "text", + SlogLogging: &SlogLogging{ + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), }, }, Metrics: &Metrics{ @@ -103,9 +111,14 @@ func TestLoadConfig(t *testing.T) { Telemetry: &Telemetry{ Logging: &Logging{ Level: "info", - Type: "slog", - SlogLogging: &SlogLogging{ //just default part - Handler: "text", + SlogLogging: &SlogLogging{ + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), }, }, Metrics: &Metrics{ @@ -118,7 +131,7 @@ func TestLoadConfig(t *testing.T) { }, }, { - name: "with slog / json logging", + name: "with slog and json logging", args: args{name: "testdata/loggingjson.hcl"}, want: &Config{ Port: port, @@ -132,9 +145,14 @@ func TestLoadConfig(t *testing.T) { Telemetry: &Telemetry{ Logging: &Logging{ Level: "info", - Type: "slog", SlogLogging: &SlogLogging{ - Handler: "json", + Json: Ptr(true), + Text: Ptr(false), + JsonOut: Ptr("stdout"), + TextOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), }, }, Metrics: &Metrics{ @@ -162,9 +180,14 @@ func TestLoadConfig(t *testing.T) { Telemetry: &Telemetry{ Logging: &Logging{ Level: "info", - Type: "otel", SlogLogging: &SlogLogging{ //just default part - Handler: "text", + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(true), }, }, Metrics: &Metrics{ @@ -177,6 +200,54 @@ func TestLoadConfig(t *testing.T) { }, wantErr: false, }, + { + name: "fail if slog text_out is not valid", + args: args{name: "testdata/not_valid_text_out.hcl"}, + want: nil, + wantErr: true, + }, + { + name: "fail if slog json_out is not valid", + args: args{name: "testdata/not_valid_json_out.hcl"}, + want: nil, + wantErr: true, + }, + { + name: "log level is default info", + args: args{name: "testdata/emptylogging.hcl"}, + want: &Config{ + Port: port, + Bind: bind, + + Nomad: &NomadServer{ + Address: nomadAddr, + }, + Validators: []Validator{}, + Mutators: []Mutator{}, + Telemetry: &Telemetry{ + Logging: &Logging{ + Level: "info", + SlogLogging: &SlogLogging{ + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), + }, + }, + Metrics: &Metrics{ + Enabled: false, + }, + Tracing: &Tracing{ + Enabled: false, + }, + }, + }, + + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -191,3 +262,111 @@ func TestLoadConfig(t *testing.T) { }) } } + +func TestLoadConfigDefaults(t *testing.T) { + + defaultConfig := &Config{ + Port: 6464, + Bind: "0.0.0.0", + + Nomad: &NomadServer{ + Address: "http://localhost:4646", + }, + Validators: []Validator{}, + Mutators: []Mutator{}, + Telemetry: &Telemetry{ + Logging: &Logging{ + Level: "info", + SlogLogging: &SlogLogging{ + Text: Ptr(true), + TextOut: Ptr("stdout"), + Json: Ptr(false), + JsonOut: Ptr("stdout"), + }, + OtelLogging: &OtelLogging{ + Enabled: Ptr(false), + }, + }, + Metrics: &Metrics{ + Enabled: false, + }, + Tracing: &Tracing{ + Enabled: false, + }, + }, + } + + tt := []struct { + name string + configAsText string + want *Config + }{ + { + name: "empty", + configAsText: ``, + want: defaultConfig, + }, + { + name: "just telemetry empty", + configAsText: `telemetry {}`, + want: defaultConfig, + }, + { + name: "just telemetry logging empty", + configAsText: `telemetry { + logging { + } + }`, + want: defaultConfig, + }, + { + name: "just telemetry logging slog empty", + configAsText: `telemetry { + logging { + slog { + } + } + }`, + want: defaultConfig, + }, + { + name: "just telemetry logging otel empty", + configAsText: `telemetry { + logging { + otel { + } + } + }`, + want: defaultConfig, + }, + { + name: "just telemetry metric empty", + configAsText: `telemetry { + metrics { + } + }`, + want: defaultConfig, + }, + { + name: "just telemetry tracing empty", + configAsText: `telemetry { + tracing { + } + }`, + want: defaultConfig, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + + dir := t.TempDir() + configFile := filepath.Join(dir, "config.hcl") + os.WriteFile(configFile, []byte(tc.configAsText), 0644) + config, err := LoadConfig(configFile) + + require.NoError(t, err) + assert.EqualValues(t, tc.want, config) + + }) + } +} diff --git a/config/testdata/emptylogging.hcl b/config/testdata/emptylogging.hcl new file mode 100644 index 0000000..962d0a0 --- /dev/null +++ b/config/testdata/emptylogging.hcl @@ -0,0 +1,5 @@ +telemetry { + logging { + + } +} diff --git a/config/testdata/loggingjson.hcl b/config/testdata/loggingjson.hcl index 916d26d..410c7e8 100644 --- a/config/testdata/loggingjson.hcl +++ b/config/testdata/loggingjson.hcl @@ -1,9 +1,9 @@ telemetry { logging { - type = "slog" slog { - handler = "json" + json = true + text = false } } } diff --git a/config/testdata/not_valid_json_out.hcl b/config/testdata/not_valid_json_out.hcl new file mode 100644 index 0000000..4e3a4f6 --- /dev/null +++ b/config/testdata/not_valid_json_out.hcl @@ -0,0 +1,8 @@ +telemetry { + logging { + + slog { + json_out = "banana" + } + } +} diff --git a/config/testdata/not_valid_text_out.hcl b/config/testdata/not_valid_text_out.hcl new file mode 100644 index 0000000..0c4a914 --- /dev/null +++ b/config/testdata/not_valid_text_out.hcl @@ -0,0 +1,8 @@ +telemetry { + logging { + + slog { + text_out = "banana" + } + } +} diff --git a/config/testdata/otelconfig.hcl b/config/testdata/otelconfig.hcl index 2f6bab0..b648bac 100644 --- a/config/testdata/otelconfig.hcl +++ b/config/testdata/otelconfig.hcl @@ -1,6 +1,9 @@ telemetry { logging { - type = "otel" + + otel { + enabled = true + } } metrics { enabled = true diff --git a/dev.Dockerfile b/dev.Dockerfile index 9747366..2d3d022 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -9,6 +9,7 @@ COPY admissionctrl ./admissionctrl COPY cmd ./cmd COPY o11y ./o11y COPY otel ./otel +COPY logutil ./logutil COPY config ./config ENV GOCACHE=/root/.cache/go-build RUN --mount=type=cache,target=/root/.cache/go-build go build -o nacp ./cmd/nacp diff --git a/example/demo/nacp.conf b/example/demo/nacp.conf index 7cb8688..2d509a9 100644 --- a/example/demo/nacp.conf +++ b/example/demo/nacp.conf @@ -1,7 +1,17 @@ telemetry { logging { - type = "otel" + level = "debug" + slog { + # text is enabled on stdout by default + json = true + json_out = "stderr" + } + otel { + enabled = true + } + } + metrics { enabled = true } diff --git a/go.mod b/go.mod index 5661568..b2c4e71 100644 --- a/go.mod +++ b/go.mod @@ -16,10 +16,13 @@ require ( github.com/notaryproject/notation-go v1.3.2 github.com/open-policy-agent/opa v1.5.1 github.com/oras-project/oras-credentials-go v0.4.0 + github.com/samber/slog-multi v1.4.1 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.37.0 go.opentelemetry.io/contrib/bridges/otelslog v0.11.0 + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 + go.opentelemetry.io/contrib/processors/minsev v0.10.0 go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 @@ -157,6 +160,8 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/samber/lo v1.51.0 // indirect + github.com/samber/slog-common v0.19.0 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shirou/gopsutil/v4 v4.25.5 // indirect @@ -178,11 +183,8 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect + go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 9fbbb8d..a8151c4 100644 --- a/go.sum +++ b/go.sum @@ -421,6 +421,12 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI= +github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= +github.com/samber/slog-multi v1.4.1 h1:OVBxOKcorBcGQVKjwlraA41JKWwHQyB/3KfzL3IJAYg= +github.com/samber/slog-multi v1.4.1/go.mod h1:im2Zi3mH/ivSY5XDj6LFcKToRIWPw1OcjSVSdXt+2d0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -491,12 +497,10 @@ go.opentelemetry.io/contrib/bridges/otelslog v0.11.0 h1:EMIiYTms4Z4m3bBuKp1VmMNR go.opentelemetry.io/contrib/bridges/otelslog v0.11.0/go.mod h1:DIEZmUR7tzuOOVUTDKvkGWtYWSHFV18Qg8+GMb8wPJw= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0 h1:wCeciVlAfb5DC8MQl/DlmAv/FVPNpQgFvI/71+hatuc= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0/go.mod h1:WfEApdZDMlLUAev/0QQpr8EJ/z0VWDKYZ5tF5RH5T1U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/contrib/processors/minsev v0.10.0 h1:E1ZidlW3lsQbX8SzOs6B3L6YgTexERGfg1H3DJL8F+s= +go.opentelemetry.io/contrib/processors/minsev v0.10.0/go.mod h1:slt7MYj7Wr3Zf/VKafBV/Z8FeBTJkawntUljgx/qZsI= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs= @@ -509,39 +513,20 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0 h1:yEX3aC9KDgvYPhuKECHbOlr5GLwH6KTjLJ1sBSkkxkc= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.13.0/go.mod h1:/GXR0tBmmkxDaCUGahvksvp66mx4yh5+cFXgSlhg0vQ= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= -go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc= -go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E= go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= go.opentelemetry.io/otel/log/logtest v0.0.0-20250606085930-0669ee0af5f6 h1:jCtB4pGg4l5/KcQlGUPRF31t/j8Gzua9fKJTXkbUBhg= go.opentelemetry.io/otel/log/logtest v0.0.0-20250606085930-0669ee0af5f6/go.mod h1:DqYX8Lp2A/RnuxXuoVtsRqYvx6Io0PtSxF5v9MnHJyU= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0= -go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY= go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= -go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0= -go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE= go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= diff --git a/logutil/logutil.go b/logutil/logutil.go new file mode 100644 index 0000000..8c8d295 --- /dev/null +++ b/logutil/logutil.go @@ -0,0 +1,143 @@ +package logutil + +import ( + "io" + "log/slog" + "os" + "strings" + + "github.com/mxab/nacp/config" + slogmulti "github.com/samber/slog-multi" + "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/contrib/processors/minsev" +) + +type Level string +type SlogOut string +type SlogFormat string + +const ( + Error Level = "ERROR" + Warn Level = "WARN" + Info Level = "INFO" + Debug Level = "DEBUG" + + SlogFormatJson SlogFormat = "json" + SlogFormatText SlogFormat = "text" +) + +func (l Level) convert() (slog.Level, minsev.Severity) { + + switch l { + case Error: + return slog.LevelError, minsev.SeverityError + case Warn: + return slog.LevelWarn, minsev.SeverityWarn + case Info: + return slog.LevelInfo, minsev.SeverityInfo + case Debug: + return slog.LevelDebug, minsev.SeverityDebug + default: + panic("unknown level") + } + +} + +type Leveler struct { + slogVar *slog.LevelVar + minsevVar *minsev.SeverityVar +} + +func NewLeveler(initial Level) *Leveler { + lev, sev := initial.convert() + slogVar := slog.LevelVar{} + slogVar.Set(lev) + minsevVar := minsev.SeverityVar{} + minsevVar.Set(sev) + return &Leveler{ + slogVar: &slogVar, + minsevVar: &minsevVar, + } +} +func (l *Leveler) Set(level Level) { + lev, sev := level.convert() + l.slogVar.Set(lev) + l.minsevVar.Set(sev) +} +func (l *Leveler) GetSlogLeveler() slog.Leveler { + return l.slogVar +} +func (l *Leveler) GetSeverietier() minsev.Severitier { + return l.minsevVar +} + +type LoggerFactory struct { + leveler *Leveler + jsonOut io.Writer + textOut io.Writer + otel bool +} + +func NewLoggerFactory(jsonOut io.Writer, textOut io.Writer, otel bool) (*LoggerFactory, *Leveler) { + leveler := NewLeveler(Info) + return &LoggerFactory{ + leveler: leveler, + jsonOut: jsonOut, + textOut: textOut, + otel: otel, + }, leveler +} +func outStrToWriter(out string) io.Writer { + switch out { + case "stdout": + return os.Stdout + case "stderr": + return os.Stderr + default: + return nil + } +} +func NewLoggerFactoryFromConfig(logging *config.Logging) (*LoggerFactory, *Leveler) { + + leveler := NewLeveler(Level(strings.ToUpper(logging.Level))) + var textOut io.Writer + if *logging.SlogLogging.Text { + textOut = outStrToWriter(*logging.SlogLogging.TextOut) + } + var jsonOut io.Writer + if *logging.SlogLogging.Json { + jsonOut = outStrToWriter(*logging.SlogLogging.JsonOut) + } + + return &LoggerFactory{ + textOut: textOut, + jsonOut: jsonOut, + otel: *logging.OtelLogging.Enabled, + leveler: leveler, + }, leveler + +} + +func (lf *LoggerFactory) GetLogger(name string) *slog.Logger { + + var handlers []slog.Handler + + if lf.jsonOut != nil { + slogHandler := slog.NewJSONHandler(lf.jsonOut, &slog.HandlerOptions{ + Level: lf.leveler.GetSlogLeveler(), + }) + handlers = append(handlers, slogHandler) + } + if lf.textOut != nil { + textHandler := slog.NewTextHandler(lf.textOut, &slog.HandlerOptions{ + Level: lf.leveler.GetSlogLeveler(), + }) + handlers = append(handlers, textHandler) + } + if lf.otel { + otelHandler := otelslog.NewHandler(name) + handlers = append(handlers, otelHandler) + } + + return slog.New(slogmulti.Fanout(handlers...)) +} diff --git a/logutil/logutil_test.go b/logutil/logutil_test.go new file mode 100644 index 0000000..62c46b3 --- /dev/null +++ b/logutil/logutil_test.go @@ -0,0 +1,321 @@ +package logutil + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" + "testing" + + "go.opentelemetry.io/contrib/processors/minsev" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/sdk/log" + + "github.com/mxab/nacp/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockExporter struct { + mock.Mock +} + +func (m *MockExporter) Export(ctx context.Context, records []log.Record) error { + args := m.Called(ctx, records) + return args.Error(0) +} +func (m *MockExporter) Shutdown(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} +func (m *MockExporter) ForceFlush(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func TestFilterLevel(t *testing.T) { + + type Messages []struct { + level slog.Level + input string + } + messages := Messages{ + + {slog.LevelDebug, "This is a debug message"}, + {slog.LevelInfo, "This is an info message"}, + {slog.LevelWarn, "This is a warning message"}, + {slog.LevelError, "This is an error message"}, + } + tests := []struct { + level slog.Level + inputs Messages + expectedMessages Messages + }{ + { + level: slog.LevelDebug, + inputs: messages, + expectedMessages: messages, + }, + { + level: slog.LevelInfo, + inputs: messages, + expectedMessages: Messages{ + {slog.LevelInfo, "This is an info message"}, + {slog.LevelWarn, "This is a warning message"}, + {slog.LevelError, "This is an error message"}, + }, + }, + { + level: slog.LevelWarn, + inputs: messages, + expectedMessages: Messages{ + {slog.LevelWarn, "This is a warning message"}, + {slog.LevelError, "This is an error message"}, + }, + }, + { + level: slog.LevelError, + inputs: messages, + expectedMessages: Messages{ + {slog.LevelError, "This is an error message"}, + }, + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s", tt.level), func(t *testing.T) { + + levler := NewLeveler(Debug) + levler.Set(Level(tt.level.String())) + + jsonOut := &bytes.Buffer{} + textOut := &bytes.Buffer{} + otelMockExporter, loggerProvider := setup(levler) + defer loggerProvider.Shutdown(t.Context()) + lf := &LoggerFactory{ + leveler: levler, + jsonOut: jsonOut, + textOut: textOut, + otel: true, + } + + logger := lf.GetLogger("dummy") + + for _, msg := range tt.inputs { + logger.Log(context.Background(), msg.level, msg.input) + } + loggerProvider.ForceFlush(t.Context()) + + records := otelMockExporter.Calls[0].Arguments.Get(1).([]log.Record) + assert.Len(t, records, len(tt.expectedMessages)) + jsonOutput := jsonOut.String() + textOutput := textOut.String() + assert.Len(t, strings.Split(strings.TrimSpace(jsonOutput), "\n"), len(tt.expectedMessages)) + assert.Len(t, strings.Split(strings.TrimSpace(textOutput), "\n"), len(tt.expectedMessages)) + + for _, expected := range tt.expectedMessages { + + assert.Contains(t, jsonOutput, fmt.Sprintf("\"msg\":\"%s\"", expected.input)) + assert.Contains(t, textOutput, fmt.Sprintf("msg=\"%s\"", expected.input)) + } + + }) + + } +} + +func TestLevelChange(t *testing.T) { + levler := NewLeveler(Debug) + + buf := &bytes.Buffer{} + otelMockExporter, loggerProvider := setup(levler) + defer loggerProvider.Shutdown(t.Context()) + lf := &LoggerFactory{ + leveler: levler, + jsonOut: buf, + otel: true, + } + + logger := lf.GetLogger("dummy") + + logger.Log(context.Background(), slog.LevelDebug, "This is a debug message") + logger.Log(context.Background(), slog.LevelInfo, "This is an info message") + logger.Log(context.Background(), slog.LevelWarn, "This is a warning message") + logger.Log(context.Background(), slog.LevelError, "This is an error message") + + levler.Set(Warn) + + logger.Log(context.Background(), slog.LevelDebug, "This is a debug message") + logger.Log(context.Background(), slog.LevelInfo, "This is an info message") + logger.Log(context.Background(), slog.LevelWarn, "This is a warning message") + logger.Log(context.Background(), slog.LevelError, "This is an error message") + + loggerProvider.ForceFlush(t.Context()) + + records := otelMockExporter.Calls[0].Arguments.Get(1).([]log.Record) + assert.Len(t, records, 6) + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + assert.Len(t, lines, 6) + assert.Contains(t, output, "This is a debug message") + assert.Contains(t, output, "This is an info message") + assert.Contains(t, output, "This is a warning message") + assert.Contains(t, output, "This is an error message") + + countDebug := 0 + countInfo := 0 + countWarn := 0 + countError := 0 + for _, line := range lines { + if strings.Contains(line, "This is a debug message") { + countDebug++ + } + if strings.Contains(line, "This is an info message") { + countInfo++ + } + if strings.Contains(line, "This is a warning message") { + countWarn++ + } + if strings.Contains(line, "This is an error message") { + countError++ + } + } + assert.Equal(t, 1, countDebug) + assert.Equal(t, 1, countInfo) + assert.Equal(t, 2, countWarn) + assert.Equal(t, 2, countError) + +} + +func setup(levler *Leveler) (mockExporter *MockExporter, loggerProvider *log.LoggerProvider) { + + mockExporter = &MockExporter{} + mockExporter.On("Export", mock.Anything, mock.Anything).Return(nil) + mockExporter.On("Shutdown", mock.Anything).Return(nil) + mockExporter.On("ForceFlush", mock.Anything).Return(nil) + batchProcessor := log.NewBatchProcessor(mockExporter) + processor := minsev.NewLogProcessor(batchProcessor, levler.GetSeverietier()) + loggerProvider = log.NewLoggerProvider(log.WithProcessor(processor)) + + global.SetLoggerProvider(loggerProvider) + return +} + +func TestNewLoggerFactoryFromConfig(t *testing.T) { + + tt := []struct { + name string + config *config.Logging + expectedTextOut io.Writer + expectedJsonOut io.Writer + expectedOtel bool + }{ + { + name: "only text", + config: &config.Logging{ + Level: "info", + SlogLogging: &config.SlogLogging{ + Text: config.Ptr(true), + TextOut: config.Ptr("stdout"), + Json: config.Ptr(false), + JsonOut: config.Ptr("stdout"), + }, + OtelLogging: &config.OtelLogging{ + Enabled: config.Ptr(false), + }, + }, + expectedTextOut: os.Stdout, + expectedJsonOut: nil, + expectedOtel: false, + }, + { + name: "only text on stderr", + config: &config.Logging{ + Level: "info", + SlogLogging: &config.SlogLogging{ + Text: config.Ptr(true), + TextOut: config.Ptr("stderr"), + Json: config.Ptr(false), + JsonOut: config.Ptr("stdout"), + }, + OtelLogging: &config.OtelLogging{ + Enabled: config.Ptr(false), + }, + }, + expectedTextOut: os.Stderr, + expectedJsonOut: nil, + expectedOtel: false, + }, + { + name: "text and json", + config: &config.Logging{ + Level: "info", + SlogLogging: &config.SlogLogging{ + Text: config.Ptr(true), + TextOut: config.Ptr("stdout"), + Json: config.Ptr(true), + JsonOut: config.Ptr("stderr"), + }, + OtelLogging: &config.OtelLogging{ + Enabled: config.Ptr(false), + }, + }, + expectedTextOut: os.Stdout, + expectedJsonOut: os.Stderr, + expectedOtel: false, + }, + + { + name: "only json to stdout", + config: &config.Logging{ + Level: "info", + SlogLogging: &config.SlogLogging{ + Text: config.Ptr(false), + TextOut: config.Ptr("stdout"), + Json: config.Ptr(true), + JsonOut: config.Ptr("stdout"), + }, + OtelLogging: &config.OtelLogging{ + Enabled: config.Ptr(false), + }, + }, + expectedTextOut: nil, + expectedJsonOut: os.Stdout, + expectedOtel: false, + }, + { + name: "only otel", + config: &config.Logging{ + Level: "info", + SlogLogging: &config.SlogLogging{ + Text: config.Ptr(false), + TextOut: config.Ptr("stdout"), + Json: config.Ptr(false), + JsonOut: config.Ptr("stdout"), + }, + OtelLogging: &config.OtelLogging{ + Enabled: config.Ptr(true), + }, + }, + expectedTextOut: nil, + expectedJsonOut: nil, + expectedOtel: true, + }, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + lf, leveler := NewLoggerFactoryFromConfig(tt.config) + + assert.NotNil(t, lf) + assert.NotNil(t, leveler) + + assert.True(t, tt.expectedTextOut == lf.textOut) + assert.True(t, tt.expectedJsonOut == lf.jsonOut) + assert.Equal(t, tt.expectedOtel, lf.otel) + }) + } +} diff --git a/otel/otel.go b/otel/otel.go index 3d35d9c..c8db3e6 100644 --- a/otel/otel.go +++ b/otel/otel.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "go.opentelemetry.io/contrib/processors/minsev" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" @@ -80,7 +81,7 @@ func SetupOTelSDKWith(ctx context.Context, loggerProvider logApi.LoggerProvider, return shutdown, flush, nil } -func SetupOTelSDK(ctx context.Context, logging, metrics, tracing bool, versionKey string) (shutdown func(context.Context) error, err error) { +func SetupOTelSDK(ctx context.Context, logging, metrics, tracing bool, versionKey string, severitier minsev.Severitier) (shutdown func(context.Context) error, err error) { res := resource.NewWithAttributes( semconv.SchemaURL, @@ -127,7 +128,7 @@ func SetupOTelSDK(ctx context.Context, logging, metrics, tracing bool, versionKe return nil, err } - loggerProvider := newLoggerProvider(loggerExporter, res) + loggerProvider := newLoggerProvider(loggerExporter, res, severitier) shutdownFnAppender(loggerProvider.Shutdown) lp = loggerProvider @@ -180,10 +181,13 @@ func newMeterProvider(reader metric.Reader, res *resource.Resource) *metric.Mete return meterProvider } -func newLoggerProvider(exporter log.Exporter, res *resource.Resource) *log.LoggerProvider { +func newLoggerProvider(exporter log.Exporter, res *resource.Resource, severietier minsev.Severitier) *log.LoggerProvider { + batchProcessor := log.NewBatchProcessor(exporter) + processor := minsev.NewLogProcessor(batchProcessor, severietier) loggerProvider := log.NewLoggerProvider( - log.WithProcessor(log.NewBatchProcessor(exporter)), + + log.WithProcessor(processor), log.WithResource(res), ) return loggerProvider diff --git a/otel/otel_test.go b/otel/otel_test.go index 87d7262..66a005f 100644 --- a/otel/otel_test.go +++ b/otel/otel_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/contrib/processors/minsev" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/log" "go.opentelemetry.io/otel/log/logtest" @@ -42,7 +43,7 @@ func TestOtlpSetup(t *testing.T) { assert := assert.New(t) require := require.New(t) - otelShutdown, err := SetupOTelSDK(ctx, true, true, true, "0.0.0") + otelShutdown, err := SetupOTelSDK(ctx, true, true, true, "0.0.0", minsev.SeverityDebug) if err != nil { t.Fatalf("failed to setup OTel SDK: %v", err) }