diff --git a/go.mod b/go.mod index 7860a15..4fb61ac 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.17 github.com/ogen-go/ogen v1.2.2 github.com/rs/cors v1.11.0 + github.com/samber/slog-multi v1.2.0 github.com/simukti/sqldb-logger v0.0.0-20230108155151-646c1a075551 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -63,6 +64,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect + github.com/samber/lo v1.38.1 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/stephenafamo/scan v0.4.2 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect @@ -77,6 +79,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/grpc v1.64.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1b12fc7..5e28015 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,10 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/slog-multi v1.2.0 h1:JIebVdmeGkCMd5/ticlmU+aDYl4tdAZBmp5uLaSzr0k= +github.com/samber/slog-multi v1.2.0/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= @@ -284,6 +288,8 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/go/.air.toml b/go/.air.toml index 307e6af..3e1f90e 100644 --- a/go/.air.toml +++ b/go/.air.toml @@ -16,7 +16,7 @@ full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] -kill_delay = "0s" +kill_delay = "5s" log = "build-errors.log" poll = false poll_interval = 0 diff --git a/go/.gitignore b/go/.gitignore index 88ab312..d2396d3 100644 --- a/go/.gitignore +++ b/go/.gitignore @@ -1,3 +1,4 @@ models/ *.db gen/ +.env diff --git a/go/cmd/bluemage/serve/serve.go b/go/cmd/bluemage/serve/serve.go index e3e35a1..e982c9a 100644 --- a/go/cmd/bluemage/serve/serve.go +++ b/go/cmd/bluemage/serve/serve.go @@ -31,9 +31,20 @@ var Cmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cfg := config.FromContext(ctx) - logOutput := log.WrapOsFile(os.Stderr) - prettyHandler := log.NewPrettyHandler(logOutput, nil) - slog.SetDefault(slog.New(prettyHandler)) + var cleanups []func() error + defer func() { + var e []error + for _, c := range cleanups { + e = append(e, c()) + } + if err := errors.Join(e...); err != nil { + fmt.Println(err.Error()) + } + }() + + logHandler, cleanup := log.NewHandler(cfg) + cleanups = append(cleanups, cleanup) + slog.SetDefault(slog.New(logHandler)) if err := os.MkdirAll(cfg.String("download.directory"), 0755); err != nil { return errs.Wrapw(err, "failed to create download directory", "path", cfg.String("download.directory")) @@ -46,6 +57,7 @@ var Cmd = &cobra.Command{ "dsn", cfg.String("db.dsn"), ) } + cleanups = append(cleanups, sqldb.Close) sqldb = sqldblogger.OpenDriver( "file:data.db", @@ -98,7 +110,7 @@ var Cmd = &cobra.Command{ return errs.Wrap(err, "failed to serve") } slog.Info("ConnectRPC server stopped") - return errors.Join(sqldb.Close(), prettyHandler.Flush()) + return nil }, SilenceUsage: true, } diff --git a/go/config/default.go b/go/config/default.go index 58933e3..0b70b04 100644 --- a/go/config/default.go +++ b/go/config/default.go @@ -26,10 +26,11 @@ func (e Entries) ToMap() map[string]any { } var DefaultConfig = Entries{ + {"log.enable", true, `Enable console logging. If TTY is detected, a pretty logging will be used. Otherwise uses JSON format.`, false}, + {"log.file.path", path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), "Log file path", false}, {"log.file.enable", true, "Enable file logging", false}, {"log.level", "info", `Log level. Possible values: "debug", "info", "warn", "error"`, false}, {"log.output", "stderr", "Log output. Possible values: \"stdout\", \"stderr\"", false}, - {"log.file.path", path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), "Log file path", false}, {"db.driver", "sqlite3", "Database driver", false}, {"db.path", path.Join(xdg.Home, ".local", "share", "bluemage", "data.db"), "Database path", false}, @@ -64,6 +65,7 @@ var DefaultConfig = Entries{ {"telemetry.openobserve.trace.auth", "Basic AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "Authorization token for the Endpoint URL", false}, {"telemetry.trace.ratio", float64(1), "Sampling ratio between 0 to 1 on how many traces are sent to the server. Value of 1 will send everything", false}, + {"telemetry.service.name", "bluemage", "Name of the service to send to telemetry server", true}, {"runtime.version", Version, "current server version", true}, {"runtime.environment", "development", "runtime environment", true}, {"flags.containerized", false, "Indicates the application is running in a container", true}, diff --git a/go/pkg/log/log.go b/go/pkg/log/log.go new file mode 100644 index 0000000..507f067 --- /dev/null +++ b/go/pkg/log/log.go @@ -0,0 +1,108 @@ +package log + +import ( + "errors" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" + slogmulti "github.com/samber/slog-multi" + "github.com/tigorlazuardi/bluemage/go/config" + "github.com/tigorlazuardi/bluemage/go/pkg/telemetry" + "gopkg.in/natefinch/lumberjack.v2" +) + +func NewHandler(cfg *config.Config) (slog.Handler, func() error) { + var handlers []slog.Handler + cleanup := func() error { return nil } + + if cfg.Bool("log.enable") { + log, sync := createStandardLogger(cfg) + cleanup = sync + handlers = append(handlers, log) + } + + if cfg.Bool("log.file.enable") { + log, clean := createFileLogger(cfg) + cl := cleanup + cleanup = func() error { + return errors.Join(cl(), clean()) + } + handlers = append(handlers, log) + } + + if cfg.Bool("telemetry.openobserve.enable") && cfg.Bool("telemetry.openobserve.log.enable") { + handlers = append(handlers, createO2Logger(cfg)) + } + + if len(handlers) == 0 { + return NullHandler{}, cleanup + } + + return slogmulti.Fanout(handlers...), cleanup +} + +func createFileLogger(cfg *config.Config) (slog.Handler, func() error) { + output := &lumberjack.Logger{ + Filename: cfg.String("log.file.path"), + MaxSize: 15, + MaxAge: 30, + MaxBackups: 10, + LocalTime: true, + } + var lvl slog.Level + _ = lvl.UnmarshalText(cfg.Bytes("log.level")) + opts := &slog.HandlerOptions{ + AddSource: cfg.Bool("log.source"), + Level: lvl, + } + return slog.NewJSONHandler(Lock(AddSync(output)), opts), output.Close +} + +func createStandardLogger(cfg *config.Config) (slog.Handler, func() error) { + var output WriteSyncer + var cleanup func() error + + if strings.ToLower(cfg.String("log.output")) == "stdout" { + output = Lock(AddSync(colorable.NewColorableStdout())) + cleanup = output.Sync + } else { + output = Lock(AddSync(colorable.NewColorableStderr())) + cleanup = output.Sync + } + + var lvl slog.Level + _ = lvl.UnmarshalText(cfg.Bytes("log.level")) + opts := &slog.HandlerOptions{ + AddSource: cfg.Bool("log.source"), + Level: lvl, + } + + if isatty.IsTerminal(os.Stdout.Fd()) { + return NewPrettyHandler(output, opts), cleanup + } else { + return slog.NewJSONHandler(output, opts), cleanup + } +} + +func createO2Logger(cfg *config.Config) slog.Handler { + var lvl slog.Level + _ = lvl.UnmarshalText(cfg.Bytes("telemetry.openobserve.log.level")) + opts := &slog.HandlerOptions{ + AddSource: cfg.Bool("telemetry.openobserve.log.source"), + Level: lvl, + } + return telemetry.NewOpenObserveHandler(telemetry.OpenObserveHandlerOptions{ + HandlerOptions: opts, + BufferSize: cfg.Int("telemetry.openobserve.log.buffer.size"), + BufferTimeout: cfg.Duration("telemetry.openobserve.log.buffer.timeout"), + Concurrency: cfg.Int("telemetry.openobserve.log.concurrency"), + Endpoint: cfg.String("telemetry.openobserve.log.endpoint"), + HTTPClient: http.DefaultClient, + Username: cfg.String("telemetry.openobserve.log.username"), + Password: cfg.String("telemetry.openobserve.log.password"), + }) +} diff --git a/go/pkg/log/null.go b/go/pkg/log/null.go new file mode 100644 index 0000000..5d069eb --- /dev/null +++ b/go/pkg/log/null.go @@ -0,0 +1,13 @@ +package log + +import ( + "context" + "log/slog" +) + +type NullHandler struct{} + +func (NullHandler) Enabled(context.Context, slog.Level) bool { return false } +func (NullHandler) Handle(context.Context, slog.Record) error { return nil } +func (nu NullHandler) WithAttrs([]slog.Attr) slog.Handler { return nu } +func (nu NullHandler) WithGroup(string) slog.Handler { return nu } diff --git a/go/pkg/telemetry/end.go b/go/pkg/telemetry/end.go index cfbc98e..fad5c9b 100644 --- a/go/pkg/telemetry/end.go +++ b/go/pkg/telemetry/end.go @@ -1,8 +1,8 @@ package telemetry import ( - "go.opencensus.io/trace" "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) // EndWithStatus ends the span with the status of the error if not nil diff --git a/go/pkg/telemetry/telemetry.go b/go/pkg/telemetry/telemetry.go index 81cc6a8..a24aa39 100644 --- a/go/pkg/telemetry/telemetry.go +++ b/go/pkg/telemetry/telemetry.go @@ -54,12 +54,7 @@ func createProvider(ctx context.Context, cfg *config.Config) (*sdktrace.TracerPr opts = append(opts, sdktrace.WithBatcher(o2exporter)) } - res := resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceNameKey.String("bluemage"), - semconv.ServiceVersionKey.String(cfg.String("runtime.version")), - attribute.String("environment", cfg.String("runtime.environment")), - ) + res := NewResource(cfg) opts = append(opts, sdktrace.WithResource(res)) @@ -72,3 +67,16 @@ func (te *Telemetry) Close() error { return te.tracer.Shutdown(ctx) } + +func NewResource(cfg *config.Config) *resource.Resource { + res, err := resource.Merge(resource.Default(), resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String("bluemage"), + semconv.ServiceVersionKey.String(cfg.String("runtime.version")), + attribute.String("environment", cfg.String("runtime.environment")), + )) + if err != nil { + panic(err) + } + return res +}