diff --git a/internal/loggerfx/doc.go b/internal/loggerfx/doc.go new file mode 100644 index 0000000..90c55fd --- /dev/null +++ b/internal/loggerfx/doc.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +/* +Package loggerfx provides a function that can be used to create a logger module +for an fx application. The logger module is based on the sallust, goschtalt, +and zap libraries and provides a simple, tested way to include a standard logger. + +Example Usage: + + package main + + import ( + "github.com/goschtalt/goschtalt" + "github.com/xmidt-org/sallust" + "github.com/xmidt-org/skeleton/internal/loggerfx" + "go.uber.org/fx" + "go.uber.org/zap" + ) + + func main() { + cfg := struct { + Logging sallust.Config + }{ + Logging: sallust.Config{ + Level: "info", + OutputPaths: []string{}, + ErrorOutputPaths: []string{}, + }, + } + + config, err := goschtalt.New( + goschtalt.ConfigIs("two_words"), + goschtalt.AddValue("built-in", goschtalt.Root, cfg, goschtalt.AsDefault()), + ) + if err != nil { + panic(err) + } + + app := fx.New( + fx.Provide( + func() *goschtalt.Config { + return config + }), + + loggerfx.Module(), + fx.Invoke(func(logger *zap.Logger) { + logger.Info("Hello, world!") + }), + loggerfx.SyncOnShutdown(), + ) + + app.Run() + } +*/ +package loggerfx diff --git a/internal/loggerfx/loggerfx.go b/internal/loggerfx/loggerfx.go new file mode 100644 index 0000000..f44bad0 --- /dev/null +++ b/internal/loggerfx/loggerfx.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package loggerfx + +import ( + "errors" + "fmt" + + "github.com/goschtalt/goschtalt" + "github.com/xmidt-org/sallust" + "go.uber.org/fx" + "go.uber.org/fx/fxevent" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +const ( + deaultConfigPath = "logging" +) + +var ( + ErrInvalidConfigPath = errors.New("configuration structure path like 'logging' or 'foo.logging' is required") +) + +// Module is function that builds the loggerfx module based on the inputs. If +// the configPath is not provided then the default path is used. +func Module(configPath ...string) fx.Option { + configPath = append(configPath, deaultConfigPath) + + var path string + for _, cp := range configPath { + if cp != "" { + path = cp + break + } + } + + // Why not use sallust.WithLogger()? It is because we want to provide the + // developer configuration based on the sallust configuration, not the zap + // options. This makes the configuration consistent between the two modes. + return fx.Options( + // Provide the logger configuration based on the input path. + fx.Provide( + goschtalt.UnmarshalFunc[sallust.Config](path), + provideLogger, + ), + + // Inform fx that we are providing a logger for it. + fx.WithLogger(func(log *zap.Logger) fxevent.Logger { + return &fxevent.ZapLogger{Logger: log} + }), + ) +} + +// DefaultConfig is a helper function that creates a default sallust configuration +// for the logger based on the application name. +func DefaultConfig(appName string) sallust.Config { + return sallust.Config{ + // Use the default zap logger configuration for most of the settings. + OutputPaths: []string{ + fmt.Sprintf("/var/log/%s/%s.log", appName, appName), + }, + ErrorOutputPaths: []string{ + fmt.Sprintf("/var/log/%s/%s.log", appName, appName), + }, + Rotation: &sallust.Rotation{ + MaxSize: 50, + MaxBackups: 10, + MaxAge: 2, + }, + } +} + +// SyncOnShutdown is a helper function that returns an fx option that will +// sync the logger on shutdown. +// +// Make sure to include this option last in the fx.Options list, so that the +// logger is the last component to be shutdown. +func SyncOnShutdown() fx.Option { + return sallust.SyncOnShutdown() +} + +// LoggerIn describes the dependencies used to bootstrap a zap logger within +// an fx application. +type LoggerIn struct { + fx.In + + // Config is the sallust configuration for the logger. This component is optional, + // and if not supplied a default zap logger will be created. + Config sallust.Config `optional:"true"` + + // DevMode is a flag that indicates if the logger should be in debug mode. + DevMode bool `name:"loggerfx.dev_mode" optional:"true"` +} + +// Create the logger and configure it based on if the program is in +// debug mode or normal mode. +func provideLogger(in LoggerIn) (*zap.Logger, error) { + if in.DevMode { + in.Config.Level = "DEBUG" + in.Config.Development = true + in.Config.Encoding = "console" + in.Config.EncoderConfig = sallust.EncoderConfig{ + TimeKey: "T", + LevelKey: "L", + NameKey: "N", + CallerKey: "C", + FunctionKey: zapcore.OmitKey, + MessageKey: "M", + StacktraceKey: "S", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: "capitalColor", + EncodeTime: "RFC3339", + EncodeDuration: "string", + EncodeCaller: "short", + } + in.Config.OutputPaths = []string{"stderr"} + in.Config.ErrorOutputPaths = []string{"stderr"} + } + return in.Config.Build() +} diff --git a/internal/loggerfx/loggerfx_test.go b/internal/loggerfx/loggerfx_test.go new file mode 100644 index 0000000..5a43bf0 --- /dev/null +++ b/internal/loggerfx/loggerfx_test.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2023 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package loggerfx + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/goschtalt/goschtalt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xmidt-org/sallust" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" + "go.uber.org/zap" +) + +/* +func TestModule(t *testing.T) { + app := fxtest.New( + t, + Module(), + fx.Invoke(func(logger *zap.Logger) { + assert.NotNil(t, logger) + }), + ) + defer app.RequireStart().RequireStop() +} +*/ + +func TestDefaultConfig(t *testing.T) { + appName := "testapp" + config := DefaultConfig(appName) + + expectedConfig := sallust.Config{ + OutputPaths: []string{ + "/var/log/testapp/testapp.log", + }, + ErrorOutputPaths: []string{ + "/var/log/testapp/testapp.log", + }, + Rotation: &sallust.Rotation{ + MaxSize: 50, + MaxBackups: 10, + MaxAge: 2, + }, + } + + assert.Equal(t, expectedConfig, config) +} + +func TestLoggerFX_EndToEnd(t *testing.T) { + // Create a temporary configuration file + loggerConfig := struct { + Logger sallust.Config + }{ + Logger: sallust.Config{ + Level: "info", + OutputPaths: []string{}, + ErrorOutputPaths: []string{}, + }, + } + loggingConfig := struct { + Logging sallust.Config + }{ + Logging: sallust.Config{ + Level: "info", + OutputPaths: []string{}, + ErrorOutputPaths: []string{}, + }, + } + + tests := []struct { + name string + input any + label string + dev bool + err bool + }{ + { + name: "empty path, no error", + input: loggingConfig, + }, { + name: "specific path, no error", + input: loggerConfig, + label: "logger", + }, { + name: "empty path, no error, dev mode", + input: loggingConfig, + dev: true, + }, { + name: "specific path, no error, dev mode", + input: loggerConfig, + label: "logger", + dev: true, + }, { + name: "specific path, error", + input: loggerConfig, + label: "invalid", + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Initialize goschtalt with the configuration file + config, err := goschtalt.New( + goschtalt.ConfigIs("two_words"), + goschtalt.AddValue("built-in", goschtalt.Root, test.input, goschtalt.AsDefault()), + ) + require.NoError(t, err) + + opt := Module() + if test.label != "" { + opt = Module(test.label) + } + + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + var stderr bytes.Buffer + done := make(chan struct{}) + go func() { + io.Copy(&stderr, r) + close(done) + }() + + opts := []fx.Option{ + fx.Supply(config), + fx.Supply(fx.Annotate(test.dev, fx.ResultTags(`name:"loggerfx.dev_mode"`))), + opt, + fx.Invoke(func(logger *zap.Logger) { + assert.NotNil(t, logger) + logger.Info("End-to-end test log message") + }), + SyncOnShutdown(), + } + + if test.err { + app := fx.New(opts...) + require.NotNil(t, app) + assert.Error(t, app.Err()) + } else { + app := fxtest.New(t, opts...) + require.NotNil(t, app) + assert.NoError(t, app.Err()) + app.RequireStart().RequireStop() + } + + // Close the writer and restore stderr + w.Close() + os.Stderr = oldStderr + <-done + }) + } +} diff --git a/logger.go b/logger.go deleted file mode 100644 index 3e14eb4..0000000 --- a/logger.go +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC -// SPDX-License-Identifier: Apache-2.0 - -package skeleton - -import ( - "github.com/xmidt-org/sallust" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -// Create the logger and configure it based on if the program is in -// debug mode or normal mode. -func provideLogger(cli *CLI, cfg sallust.Config) (*zap.Logger, error) { - if cli.Dev { - cfg.Level = "DEBUG" - cfg.Development = true - cfg.Encoding = "console" - cfg.EncoderConfig = sallust.EncoderConfig{ - TimeKey: "T", - LevelKey: "L", - NameKey: "N", - CallerKey: "C", - FunctionKey: zapcore.OmitKey, - MessageKey: "M", - StacktraceKey: "S", - LineEnding: zapcore.DefaultLineEnding, - EncodeLevel: "capitalColor", - EncodeTime: "RFC3339", - EncodeDuration: "string", - EncodeCaller: "short", - } - cfg.OutputPaths = []string{"stderr"} - cfg.ErrorOutputPaths = []string{"stderr"} - } - return cfg.Build() -} diff --git a/skeleton.go b/skeleton.go index 65df4cc..68ae1c8 100644 --- a/skeleton.go +++ b/skeleton.go @@ -14,16 +14,14 @@ import ( _ "github.com/goschtalt/yaml-encoder" "github.com/xmidt-org/arrange/arrangehttp" "github.com/xmidt-org/candlelight" - "github.com/xmidt-org/sallust" "github.com/xmidt-org/skeleton/internal/apiauth" + "github.com/xmidt-org/skeleton/internal/loggerfx" "github.com/xmidt-org/skeleton/internal/metrics" "github.com/xmidt-org/skeleton/internal/oker" "github.com/xmidt-org/touchstone" "github.com/xmidt-org/touchstone/touchhttp" "go.uber.org/fx" - "go.uber.org/fx/fxevent" - "go.uber.org/zap" ) const ( @@ -56,23 +54,28 @@ func Main(args []string, run bool) error { // nolint: funlen // Capture the command line arguments. cli *CLI + + // Capture the error. + err error ) + cli, err = provideCLIWithOpts(args, false) + if err != nil { + return err + } + app := fx.New( - fx.Supply(cliArgs(args)), + fx.Supply(cli), fx.Populate(&g), fx.Populate(&gscfg), - fx.Populate(&cli), - fx.WithLogger(func(log *zap.Logger) fxevent.Logger { - return &fxevent.ZapLogger{Logger: log} - }), + loggerfx.Module(), + fx.Supply( + fx.Annotate(cli.Dev, fx.ResultTags(`name:"loggerfx.dev_mode"`)), // nolint: staticcheck + ), fx.Provide( - provideCLI, - provideLogger, provideConfig, - goschtalt.UnmarshalFunc[sallust.Config]("logging"), goschtalt.UnmarshalFunc[candlelight.Config]("tracing"), goschtalt.UnmarshalFunc[touchstone.Config]("prometheus"), goschtalt.UnmarshalFunc[touchhttp.Config]("prometheus_handler"), @@ -126,9 +129,11 @@ func Main(args []string, run bool) error { // nolint: funlen touchstone.Provide(), touchhttp.Provide(), metrics.Provide(), + + loggerfx.SyncOnShutdown(), ) - if cli != nil && cli.Graph != "" { + if cli.Graph != "" { _ = os.WriteFile(cli.Graph, []byte(g), 0600) } @@ -151,15 +156,8 @@ func Main(args []string, run bool) error { // nolint: funlen return nil } -// Provides a named type so it's a bit easier to flow through & use in fx. -type cliArgs []string - // Handle the CLI processing and return the processed input. -func provideCLI(args cliArgs) (*CLI, error) { - return provideCLIWithOpts(args, false) -} - -func provideCLIWithOpts(args cliArgs, testOpts bool) (*CLI, error) { +func provideCLIWithOpts(args []string, testOpts bool) (*CLI, error) { var cli CLI // Create a no-op option to satisfy the kong.New() call.