-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
better CLI wrapping & begin using
log/slog
- Loading branch information
Showing
7 changed files
with
198 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package cli | ||
|
||
import ( | ||
"context" | ||
"log" | ||
|
||
"github.com/function61/gokit/log/logex" | ||
"github.com/function61/gokit/os/osutil" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
// below intended to be deprecated soon | ||
|
||
func RunnerNoArgs(run func(ctx context.Context, logger *log.Logger) error) func(*cobra.Command, []string) { | ||
return func(_ *cobra.Command, _ []string) { | ||
logger := logex.StandardLogger() | ||
|
||
osutil.ExitIfError(run( | ||
osutil.CancelOnInterruptOrTerminate(logger), | ||
logger)) | ||
} | ||
} | ||
|
||
func Runner(run func(ctx context.Context, args []string, logger *log.Logger) error) func(*cobra.Command, []string) { | ||
return func(_ *cobra.Command, args []string) { | ||
logger := logex.StandardLogger() | ||
|
||
osutil.ExitIfError(run( | ||
osutil.CancelOnInterruptOrTerminate(logger), | ||
args, | ||
logger)) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,32 +1,56 @@ | ||
// Cobra wrappers to wrap awkward API (no exit codes and no built-in "ctrl-c => cancel" support) | ||
// Making CLI commands have some quality without too much boilerplate. | ||
package cli | ||
|
||
import ( | ||
"context" | ||
"log" | ||
"os" | ||
|
||
"github.com/function61/gokit/log/logex" | ||
"github.com/function61/gokit/app/dynversion" | ||
"github.com/function61/gokit/os/osutil" | ||
"github.com/spf13/cobra" | ||
"github.com/spf13/pflag" | ||
) | ||
|
||
func RunnerNoArgs(run func(ctx context.Context, logger *log.Logger) error) func(*cobra.Command, []string) { | ||
return func(_ *cobra.Command, _ []string) { | ||
logger := logex.StandardLogger() | ||
// wraps the `Execute()` call of the command to inject boilerplate details like `Use`, `Version` and | ||
// handling of error to `Command.Execute()` (such as flag validation, missing command etc.) | ||
func Execute(app *cobra.Command) { | ||
// dirty to mutate after-the-fact | ||
|
||
osutil.ExitIfError(run( | ||
osutil.CancelOnInterruptOrTerminate(logger), | ||
logger)) | ||
} | ||
app.Use = os.Args[0] | ||
app.Version = dynversion.Version | ||
// hide the default "completion" subcommand from polluting UX (it can still be used). https://github.com/spf13/cobra/issues/1507 | ||
app.CompletionOptions = cobra.CompletionOptions{HiddenDefaultCmd: true} | ||
|
||
AddLogLevelControls(app.Flags()) | ||
|
||
osutil.ExitIfError(app.Execute()) | ||
} | ||
|
||
func Runner(run func(ctx context.Context, args []string, logger *log.Logger) error) func(*cobra.Command, []string) { | ||
// fixes problems of cobra commands' bare run callbacks with regards to application quality: | ||
// 1. logging not configured | ||
// 2. no interrupt handling | ||
// 3. no error handling | ||
func WrapRun(run func(ctx context.Context, args []string) error) func(*cobra.Command, []string) { | ||
return func(_ *cobra.Command, args []string) { | ||
logger := logex.StandardLogger() | ||
// handle logging | ||
configureLogging() | ||
|
||
// handle interrupts | ||
ctx := notifyContextInterruptOrTerminate() | ||
|
||
osutil.ExitIfError(run( | ||
osutil.CancelOnInterruptOrTerminate(logger), | ||
args, | ||
logger)) | ||
// run the actual code (jump from CLI context to higher-level application context) | ||
// this can be kinda read as: | ||
// output = logic(input) | ||
err := run(ctx, args) | ||
|
||
// handle errors | ||
osutil.ExitIfError(err) | ||
} | ||
} | ||
|
||
// adds CLI flags that control the logging level | ||
func AddLogLevelControls(flags *pflag.FlagSet) { | ||
flags.BoolVarP(&logLevelVerbose, "verbose", "v", logLevelVerbose, "Include debug-level logs") | ||
|
||
// TODO: maybe add a "quiet" level as well | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package cli | ||
|
||
import ( | ||
"log/slog" | ||
"os" | ||
"time" | ||
|
||
"github.com/lmittmann/tint" | ||
"github.com/mattn/go-isatty" | ||
) | ||
|
||
var ( | ||
logLevelVerbose = false | ||
discardAttr = slog.Attr{} // zero `Attr` means discard | ||
) | ||
|
||
func configureLogging() { | ||
logLevel := func() slog.Level { | ||
if logLevelVerbose { | ||
return slog.LevelDebug | ||
} else { | ||
return slog.LevelInfo | ||
} | ||
}() | ||
|
||
addSource := func() bool { | ||
if logLevelVerbose { | ||
return true | ||
} else { | ||
return false | ||
} | ||
}() | ||
|
||
errorStream := os.Stderr | ||
errorStreamIsUserTerminal := isatty.IsTerminal(errorStream.Fd()) | ||
|
||
logHandler := func() slog.Handler { | ||
if errorStreamIsUserTerminal { // output format optimized to looking at from terminal | ||
return tint.NewHandler(errorStream, &tint.Options{ | ||
Level: logLevel, | ||
AddSource: addSource, | ||
TimeFormat: time.TimeOnly, // not using freedom time (`time.Kitchen`) | ||
// intentionally not giving `ReplaceAttr` because for terminal we can always include times | ||
}) | ||
} else { // "production" log output | ||
logAttrReplacer := timeRemoverAttrReplacer | ||
if !logsShouldOmitTime() { | ||
logAttrReplacer = nil | ||
} | ||
|
||
return slog.NewTextHandler(errorStream, &slog.HandlerOptions{ | ||
Level: logLevel, | ||
AddSource: addSource, | ||
ReplaceAttr: logAttrReplacer, | ||
}) | ||
} | ||
}() | ||
|
||
// expecting the apps to just use the global logger | ||
slog.SetDefault(slog.New(logHandler)) | ||
} | ||
|
||
// if our logs are redirected to journald or similar which already add timestamps don't add double timestamps | ||
func logsShouldOmitTime() bool { | ||
// "This permits invoked processes to safely detect whether their standard output or standard | ||
// error output are connected to the journal." | ||
// https://www.freedesktop.org/software/systemd/man/systemd.exec.html#%24JOURNAL_STREAM | ||
systemdJournal := os.Getenv("JOURNAL_STREAM") != "" | ||
|
||
// explicitly asked, e.g. set by orchestrator when running in Docker with log redirection taken care of | ||
explicitSuppress := os.Getenv("LOGGER_SUPPRESS_TIMESTAMPS") == "1" | ||
|
||
return systemdJournal || explicitSuppress | ||
} | ||
|
||
func timeRemoverAttrReplacer(groups []string, a slog.Attr) slog.Attr { | ||
if a.Key == slog.TimeKey { | ||
return discardAttr | ||
} else { | ||
return a | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package cli | ||
|
||
import ( | ||
"context" | ||
"log/slog" | ||
"os" | ||
"os/signal" | ||
"syscall" | ||
) | ||
|
||
// same as `signal.NotifyContext()` but logs output when got signal to give useful feedback to user | ||
// that we have begun teardown. this feedback becomes extremely important if the teardown process | ||
// takes time or gets stuck. | ||
func notifyContextInterruptOrTerminate() context.Context { | ||
ctx, cancel := context.WithCancel(context.Background()) | ||
|
||
// need a buffered channel because the sending side is non-blocking | ||
ch := make(chan os.Signal, 1) | ||
|
||
// "The SIGINT signal is sent to a process by its controlling terminal when a user wishes to interrupt the process" | ||
// "The SIGTERM signal is sent to a process to request its termination" | ||
// "SIGINT is nearly identical to SIGTERM" | ||
// https://en.wikipedia.org/wiki/Signal_(IPC) | ||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) | ||
|
||
go func() { | ||
slog.Info("STOPPING. (If stuck, send sig again to force exit.)", "signal", <-ch) | ||
|
||
// stop accepting signals on the channel. this undoes the effect of this func, | ||
// and thus makes the process terminate on the next received signal (so you can stop | ||
// your program if the cleanup code gets stuck) | ||
signal.Stop(ch) | ||
|
||
cancel() | ||
}() | ||
|
||
return ctx | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters