From 3c3718d14dc05471c034ca08d983c86b4f3ae084 Mon Sep 17 00:00:00 2001 From: Justen Stall <39888103+justenstall@users.noreply.github.com> Date: Wed, 29 May 2024 11:21:45 -0400 Subject: [PATCH] feat(logging): support JSON and logfmt logging with --log-fmt flag Signed-off-by: Justen Stall <39888103+justenstall@users.noreply.github.com> --- cmd/hops/main.go | 130 ++++++++++++++++++++++++++---- go.mod | 2 - go.sum | 11 --- internal/actions/copy.go | 4 +- internal/o/styles.go | 13 ++- internal/utils/logutil/logutil.go | 71 ++++++++-------- internal/utils/utils.go | 9 +++ 7 files changed, 165 insertions(+), 75 deletions(-) diff --git a/cmd/hops/main.go b/cmd/hops/main.go index 7bf05d9..c6afc33 100644 --- a/cmd/hops/main.go +++ b/cmd/hops/main.go @@ -1,9 +1,10 @@ package main import ( + "errors" + "io" "log/slog" "os" - "time" "github.com/charmbracelet/log" "github.com/muesli/termenv" @@ -33,8 +34,6 @@ func main() { root := cli.NewCLI(info.Version) // Create the root command root.SilenceUsage = true // Silence usage when root is called - logopts := logutil.WithPersistentVerbosityFlags(root) // Add "quiet", "verbose", and "debug" flags - // Layout of embedded documentation to surface in the help command // and generate in the gendocs command embeddedDocs := docs.Embedded(root) @@ -49,33 +48,55 @@ func main() { docsCmd, ) + // Logger flags + logopts := logutil.WithPersistentVerbosityFlags(root) // Add "quiet", "verbose", and "debug" flags + var logfmt string + root.PersistentFlags().StringVar(&logfmt, "log-fmt", "text", "Set format for log messages. Options: text, json") + // var logfile string + // root.PersistentFlags().StringVar(&logfile, "log-file", "", "Send JSON logs to a file") + // Restores the original ANSI processing state on Windows var restoreWindowsANSI func() error // The pre run function logs build info and sets the default output writer - root.PersistentPreRun = func(cmd *cobra.Command, _ []string) { - level := logopts.LogLevel(slog.LevelInfo) // parse log level flags - cmd.Println("log level: ", level) - logger := log.NewWithOptions(cmd.OutOrStdout(), log.Options{ - TimeFormat: time.Kitchen, // set human-readable time - Level: log.Level(level), // parse verbose/debug flags - }) - logger.SetStyles(o.LogStyles()) // set homebrew-inspired styles - log.SetDefault(logger) // set as default charmbracelet/log.Logger - slog.SetDefault(slog.New(log.Default())) // set as the default slog.Logger + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + // Create [log/slog.Handler] using flag configuration + level := logopts.LogLevel(slog.LevelInfo) // evaluate log level flags + handler, err := newTermHandler( + cmd.ErrOrStderr(), + level, // log level flags + logfmt, // log format flag + // logfile, // log file flag + ) + if err != nil { + return err + } + + // Set default [log/slog.Logger] + slog.SetDefault(slog.New(handler)) // Set termenv default output termenv.SetDefaultOutput(termenv.NewOutput(cmd.OutOrStdout())) // Enable ANSI processing on Windows color output - var err error restoreWindowsANSI, err = termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput()) if err != nil { slog.Error("error enabling ANSI processing", slog.String("error", err.Error())) } - slog.Debug("Starting logger", slog.String("format", "text"), slog.String("level", level.String())) - slog.Debug("Software", slog.String("version", info.Version)) // Log version info - slog.Debug("Software details", slog.Any("info", info)) // Log build info + // slog.Log(ctx, logutil.LevelTrace, + slog.Log(ctx, slog.LevelDebug, + "Starting logger", slog.String("format", "text"), slog.String("verbosity", level.String())) + // slog.Log(ctx, logutil.LevelVerbose, + slog.Log(ctx, slog.LevelDebug, + "Software", slog.String("version", info.Version)) // Log version info + slog.Debug("Software details", slog.Attr{ // Log build info + Key: "info", + Value: slog.GroupValue(logutil.VersionAttrs(info)...), + }) + + return nil } // The post run function restores the terminal @@ -90,3 +111,78 @@ func main() { os.Exit(1) } } + +func newTermHandler(w io.Writer, level slog.Level, format string) (slog.Handler, error) { + opts := log.Options{ + Level: log.Level(level), // parse verbose/debug flags + } + + switch format { + case "text", "": + opts.Formatter = log.TextFormatter + case "logfmt": + opts.Formatter = log.LogfmtFormatter + opts.ReportTimestamp = true + case "json": + opts.Formatter = log.JSONFormatter + opts.ReportTimestamp = true + default: + return nil, errors.New("starting logger: unsupported log format \"" + format + "\"") + } + + logger := log.NewWithOptions(w, opts) + logger.SetStyles(o.LogStyles()) // set homebrew-inspired styles + log.SetDefault(logger) // set as default charmbracelet/log.Logger + + return logger, nil +} + +/* +func logHandler(level slog.Level, format, file string) (slog.Handler, error) { + var termHandler slog.Handler + + switch format { + // Text logger using charmbracelet/log + case "text", "": + logger := log.NewWithOptions(os.Stdout, log.Options{ + // TimeFormat: time.Kitchen, // set human-readable time + Level: log.Level(level), // parse verbose/debug flags + }) + logger.SetStyles(o.LogStyles()) // set homebrew-inspired styles + log.SetDefault(logger) // set as default charmbracelet/log.Logger + termHandler = logger // use as termHandler + // JSON logger using zerolog + case "json": + zlogger := zerolog.New(os.Stderr) + termHandler = slogzerolog.Option{ + Level: level, + Logger: &zlogger, + }.NewZerologHandler() + default: + return nil, errors.New("starting logger: unsupported log format \"" + format + "\"") + } + + // Return terminal handler if no file is specified + if file == "" { + return termHandler, nil + } + + // Create log file + f, err := os.Create(file) + if err != nil { + return nil, fmt.Errorf("creating log file: %w", err) + } + + // Create file logger + fileLogger := zerolog.New(f) + + // Create log/slog.Handler + fileHandler := slogzerolog.Option{ + Level: logutil.LevelTrace, + Logger: &fileLogger, + }.NewZerologHandler() + + // Create fanout logger to duplicate messages to file and terminal handler + return slogmulti.Fanout(termHandler, fileHandler), nil +} +*/ diff --git a/go.mod b/go.mod index 8c8140a..63adb6f 100644 --- a/go.mod +++ b/go.mod @@ -35,14 +35,12 @@ require ( github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.33.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect diff --git a/go.sum b/go.sum index 869f2f7..bdd7298 100644 --- a/go.sum +++ b/go.sum @@ -12,7 +12,6 @@ github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMt github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -29,7 +28,6 @@ github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0 github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -48,10 +46,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -78,9 +72,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -105,9 +96,7 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= diff --git a/internal/actions/copy.go b/internal/actions/copy.go index f4ab61f..17fdf8e 100644 --- a/internal/actions/copy.go +++ b/internal/actions/copy.go @@ -377,9 +377,9 @@ func pushMetadata(ctx context.Context, dst oras.Target, manifestDesc ocispec.Des if err != nil { return ocispec.Descriptor{}, fmt.Errorf("pushing platform metadata: %w", err) } - l.Debug("Pushed metadata for platform", logutil.OCIPlatformValue(manifestDesc.Platform), logutil.DescriptorGroup(configDesc)) + configDesc.Platform = manifestDesc.Platform // set platform field on config descriptor - configDesc.Platform = manifestDesc.Platform // preserve platform + l.Debug("Pushed metadata for platform", logutil.DescriptorGroup(configDesc)) manifestOptions = platformMetadatManifestOptions(f.FullName, f.Version(), plat, manifestDesc, configDesc) } else { diff --git a/internal/o/styles.go b/internal/o/styles.go index 12d92b3..26ba96d 100644 --- a/internal/o/styles.go +++ b/internal/o/styles.go @@ -28,6 +28,7 @@ var ( green = termenv.ANSIGreen blue = termenv.ANSIBlue magenta = termenv.ANSIMagenta + cyan = termenv.ANSICyan styleBold = output.String().Bold() styleUnderline = output.String().Underline() @@ -94,6 +95,7 @@ func StyleYellow(s string) string { // Returns log styles for charmbracelet/log. func LogStyles() *log.Styles { styles := log.DefaultStyles() + arrow := lipgloss.NewStyle().SetString(Arrow) styles.Levels[log.ErrorLevel] = lipgloss.NewStyle(). SetString("Error:"). @@ -101,13 +103,16 @@ func LogStyles() *log.Styles { styles.Levels[log.WarnLevel] = lipgloss.NewStyle(). SetString("Warning:"). Foreground(lipgloss.ANSIColor(yellow)) - styles.Levels[log.InfoLevel] = lipgloss.NewStyle().SetString(Arrow).Foreground(lipgloss.ANSIColor(blue)) - styles.Levels[log.DebugLevel] = lipgloss.NewStyle(). - SetString(Arrow). + styles.Levels[log.InfoLevel] = arrow. + Foreground(lipgloss.ANSIColor(blue)) + styles.Levels[log.DebugLevel] = arrow. Foreground(lipgloss.ANSIColor(magenta)) + styles.Levels[log.Level(logutil.LevelTrace)] = arrow. + Foreground(lipgloss.ANSIColor(cyan)) // Add a custom style for key err/error - styles.Keys[logutil.ErrKey] = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(red)) + styles.Keys[logutil.ErrKey] = lipgloss.NewStyle(). + Foreground(lipgloss.ANSIColor(red)) return styles } diff --git a/internal/utils/logutil/logutil.go b/internal/utils/logutil/logutil.go index 532d82e..2b654d4 100644 --- a/internal/utils/logutil/logutil.go +++ b/internal/utils/logutil/logutil.go @@ -6,17 +6,26 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" + + "gitlab.com/act3-ai/asce/go-common/pkg/version" + + "github.com/act3-ai/hops/internal/utils" ) -// ErrKey is the slog attribute key used for errors in log messages. +const ( + LevelTrace = slog.LevelDebug * 2 // trace = double debug + LevelVerbose = slog.LevelInfo - 1 // verbose = one step more verbose than info +) + +// ErrKey is the key used for errors in [log/slog] attributes. const ErrKey = "err" -// ErrAttr produces a slog.Attr for errors. +// ErrAttr produces a [log/slog.Attr] for errors. func ErrAttr(err error) slog.Attr { return slog.Any(ErrKey, err) } -// OCIPlatformValue formats an OCI platform for logging. +// OCIPlatformValue formats an [ocispec.Platform] as a [log/slog.Attr]. func OCIPlatformValue(plat *ocispec.Platform) slog.Attr { if plat == nil { return slog.String("platform", "nil") @@ -27,7 +36,7 @@ func OCIPlatformValue(plat *ocispec.Platform) slog.Attr { } } -// DescriptorGroup formats an OCI descriptor for logging. +// DescriptorGroup formats an [ocispec.Descriptor] as a [log/slog.Attr]. func DescriptorGroup(desc ocispec.Descriptor) slog.Attr { return slog.Attr{ Key: "desc", @@ -45,55 +54,30 @@ func ociPlatformAttrs(plat ocispec.Platform) []slog.Attr { // DescriptorAttrs formats a descriptor as a list of attributes. func DescriptorAttrs(desc ocispec.Descriptor) []any { - attrs := []any{ + return utils.ToAny(descriptorAttrs(desc)) +} + +// descriptorAttrs formats a descriptor as a list of attributes. +func descriptorAttrs(desc ocispec.Descriptor) []slog.Attr { + attrs := []slog.Attr{ slog.String("mediaType", desc.MediaType), + slog.String("digest", desc.Digest.String()), + slog.Int64("size", desc.Size), } - if desc.ArtifactType != "" { attrs = append(attrs, slog.String("artifactType", desc.ArtifactType)) } - if desc.Annotations != nil { if v, ok := desc.Annotations[ocispec.AnnotationTitle]; ok { attrs = append(attrs, slog.String("annotations."+ocispec.AnnotationTitle, v)) } } - - if desc.Platform != nil { - attrs = append(attrs, OCIPlatformValue(desc.Platform)) - } - - // Add size and digest last for more readability - return append(attrs, - slog.Int64("size", desc.Size), - slog.String("digest", desc.Digest.String())) -} - -// descriptorAttrs formats a descriptor as a list of attributes. -func descriptorAttrs(desc ocispec.Descriptor) []slog.Attr { - attrs := []slog.Attr{ - slog.String("mediaType", desc.MediaType), - slog.String("digest", desc.Digest.String()), - slog.Int64("size", desc.Size), - } if desc.Platform != nil { attrs = append(attrs, OCIPlatformValue(desc.Platform)) } return attrs } -// // PlainDescriptor returns a plain descriptor that contains only MediaType, Digest and -// // Size. -// // -// // From: https://github.com/oras-project/oras-go/blob/main/internal/descriptor/descriptor.go -// func PlainDescriptor(desc ocispec.Descriptor) ocispec.Descriptor { -// return ocispec.Descriptor{ -// MediaType: desc.MediaType, -// Digest: desc.Digest, -// Size: desc.Size, -// } -// } - // WithLogging adds logging at level for the OnCopySkipped, PostCopy, and OnMounted functions. func WithLogging(logger *slog.Logger, level slog.Level, opts *oras.CopyGraphOptions) oras.CopyGraphOptions { dolog := func(ctx context.Context, msg string, desc ocispec.Descriptor) { @@ -106,8 +90,6 @@ func WithLogging(logger *slog.Logger, level slog.Level, opts *oras.CopyGraphOpti opts.OnCopySkipped = func(ctx context.Context, desc ocispec.Descriptor) error { dolog(ctx, "Skipped copy for artifact", desc) - // fmt.Println(debugutil.DebugMarshalJSON(desc)) - if onCopySkipped != nil { return onCopySkipped(ctx, desc) } @@ -131,3 +113,14 @@ func WithLogging(logger *slog.Logger, level slog.Level, opts *oras.CopyGraphOpti } return *opts } + +// VersionAttrs formats version info as a list of [log/slog.Attr]. +func VersionAttrs(info version.Info) []slog.Attr { + attrs := []slog.Attr{ + slog.String("version", info.Version), + slog.String("commit", info.Commit), + slog.Bool("dirty", info.Dirty), + slog.String("built", info.Built), + } + return attrs +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index b7b7c13..b5917b7 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -112,3 +112,12 @@ func IndexIfOK[T any](list []T, i int) (T, bool) { } return *new(T), false } + +// ToAny casts a slice to []any. +func ToAny[T any](list []T) []any { + anyList := make([]any, len(list)) + for i := range list { + anyList[i] = list[i] + } + return anyList +}