-
Notifications
You must be signed in to change notification settings - Fork 0
Add logging package and /api/log endpoint #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "log/slog" | ||
| "net/http" | ||
| "os" | ||
| "os/signal" | ||
| "strings" | ||
| "syscall" | ||
| "time" | ||
|
|
||
| "github.com/rustyeddy/otto/logging" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| const serverAddr = ":8011" | ||
|
|
||
| var ( | ||
| logLevel string | ||
| logFormat string | ||
| logOutput string | ||
| logFile string | ||
| ) | ||
|
|
||
| var rootCmd = &cobra.Command{ | ||
| Use: "otto", | ||
| Short: "OttO IoT server", | ||
| SilenceUsage: true, | ||
| SilenceErrors: true, | ||
| } | ||
|
|
||
| var serveCmd = &cobra.Command{ | ||
| Use: "serve", | ||
| Short: "Start the HTTP server", | ||
| RunE: runServe, | ||
| } | ||
|
|
||
| func init() { | ||
| serveCmd.Flags().StringVar(&logLevel, "log-level", logging.DefaultLevel, "Log level (debug, info, warn, error)") | ||
| serveCmd.Flags().StringVar(&logFormat, "log-format", logging.DefaultFormat, "Log format (text, json)") | ||
| serveCmd.Flags().StringVar(&logOutput, "log-output", logging.DefaultOutput, "Log output (stdout, stderr, file, string)") | ||
| serveCmd.Flags().StringVar(&logFile, "log-file", "", "Log file path (required when log-output=file)") | ||
| rootCmd.AddCommand(serveCmd) | ||
| } | ||
|
|
||
| func main() { | ||
| if err := rootCmd.Execute(); err != nil { | ||
| slog.Error("command failed", "error", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| func runServe(cmd *cobra.Command, args []string) error { | ||
| if strings.EqualFold(logOutput, "file") && strings.TrimSpace(logFile) == "" { | ||
| return errors.New("log-output=file requires --log-file") | ||
| } | ||
|
|
||
| cfg := logging.Config{ | ||
| Level: logLevel, | ||
| Format: logFormat, | ||
| Output: logOutput, | ||
| FilePath: logFile, | ||
| } | ||
|
|
||
| logService, err := logging.NewService(cfg) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.Handle("/api/log", logService) | ||
|
|
||
| server := &http.Server{ | ||
| Addr: serverAddr, | ||
| Handler: mux, | ||
| } | ||
|
|
||
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| defer stop() | ||
|
|
||
| errCh := make(chan error, 1) | ||
| go func() { | ||
| errCh <- server.ListenAndServe() | ||
| }() | ||
|
|
||
| select { | ||
| case <-ctx.Done(): | ||
| shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
| if err := server.Shutdown(shutdownCtx); err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| case err := <-errCh: | ||
| if err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| return err | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
Comment on lines
+1
to
+102
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package logging | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "os" | ||
| "strings" | ||
| ) | ||
|
|
||
| // ParseLevel converts a string into a slog.Level. | ||
| func ParseLevel(s string) (slog.Level, error) { | ||
| value := strings.ToLower(strings.TrimSpace(s)) | ||
| if value == "warning" { | ||
| value = "warn" | ||
| } | ||
|
|
||
| switch value { | ||
| case "debug": | ||
| return slog.LevelDebug, nil | ||
| case "info": | ||
| return slog.LevelInfo, nil | ||
| case "warn": | ||
| return slog.LevelWarn, nil | ||
| case "error": | ||
| return slog.LevelError, nil | ||
| default: | ||
| return slog.LevelInfo, fmt.Errorf("unsupported level %q", s) | ||
| } | ||
| } | ||
|
|
||
| // Build builds a slog.Logger using the provided configuration. | ||
| func Build(cfg Config) (*slog.Logger, io.Closer, *bytes.Buffer, error) { | ||
| cfg, err := normalizeConfig(cfg) | ||
| if err != nil { | ||
| return nil, nil, nil, err | ||
| } | ||
|
|
||
| level, err := ParseLevel(cfg.Level) | ||
| if err != nil { | ||
| return nil, nil, nil, err | ||
| } | ||
|
|
||
| var ( | ||
| writer io.Writer | ||
| closer io.Closer | ||
| buf *bytes.Buffer | ||
| ) | ||
|
|
||
| switch cfg.Output { | ||
| case "stdout": | ||
| writer = os.Stdout | ||
| case "stderr": | ||
| writer = os.Stderr | ||
| case "file": | ||
| file, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) | ||
| if err != nil { | ||
| return nil, nil, nil, fmt.Errorf("open log file: %w", err) | ||
| } | ||
| writer = file | ||
| closer = file | ||
| case "string": | ||
| if cfg.Buffer != nil { | ||
| buf = cfg.Buffer | ||
| } else { | ||
| buf = &bytes.Buffer{} | ||
| } | ||
| writer = buf | ||
| default: | ||
| return nil, nil, nil, fmt.Errorf("unsupported output %q", cfg.Output) | ||
| } | ||
|
|
||
| opts := &slog.HandlerOptions{Level: level} | ||
| var handler slog.Handler | ||
| if cfg.Format == "json" { | ||
| handler = slog.NewJSONHandler(writer, opts) | ||
| } else { | ||
| handler = slog.NewTextHandler(writer, opts) | ||
| } | ||
|
|
||
| logger := slog.New(handler) | ||
| return logger, closer, buf, nil | ||
| } | ||
|
Comment on lines
+33
to
+84
|
||
|
|
||
| // ApplyGlobal applies the logger and level to slog defaults. | ||
| func ApplyGlobal(logger *slog.Logger, level slog.Level) { | ||
| if logger == nil { | ||
| return | ||
| } | ||
| slog.SetDefault(logger) | ||
| slog.SetLogLoggerLevel(level) | ||
| } | ||
|
Comment on lines
+86
to
+93
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package logging | ||
|
||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "strings" | ||
| ) | ||
|
|
||
| const ( | ||
| DefaultLevel = "info" | ||
| DefaultFormat = "text" | ||
| DefaultOutput = "stdout" | ||
| ) | ||
|
|
||
| // Config defines configuration for logging outputs and formatting. | ||
| type Config struct { | ||
| Level string `json:"level"` | ||
| Format string `json:"format"` | ||
| Output string `json:"output"` | ||
| FilePath string `json:"filePath,omitempty"` | ||
| Buffer *bytes.Buffer `json:"-"` | ||
| } | ||
|
|
||
| // DefaultConfig returns the default logging configuration. | ||
| func DefaultConfig() Config { | ||
| return Config{ | ||
| Level: DefaultLevel, | ||
| Format: DefaultFormat, | ||
| Output: DefaultOutput, | ||
| } | ||
| } | ||
|
|
||
| // WithDefaults fills in empty fields with defaults. | ||
| func (c Config) WithDefaults() Config { | ||
| if strings.TrimSpace(c.Level) == "" { | ||
| c.Level = DefaultLevel | ||
| } | ||
| if strings.TrimSpace(c.Format) == "" { | ||
| c.Format = DefaultFormat | ||
| } | ||
| if strings.TrimSpace(c.Output) == "" { | ||
| c.Output = DefaultOutput | ||
| } | ||
| return c | ||
| } | ||
|
Comment on lines
+33
to
+45
|
||
|
|
||
| // Normalize lowercases string fields and clears file/buffer fields when not used. | ||
| func (c Config) Normalize() Config { | ||
| c.Level = strings.ToLower(strings.TrimSpace(c.Level)) | ||
| c.Format = strings.ToLower(strings.TrimSpace(c.Format)) | ||
| c.Output = strings.ToLower(strings.TrimSpace(c.Output)) | ||
| if c.Output != "file" { | ||
| c.FilePath = "" | ||
| } | ||
| if c.Output != "string" { | ||
| c.Buffer = nil | ||
| } | ||
| return c | ||
| } | ||
|
Comment on lines
+47
to
+59
|
||
|
|
||
| // Validate checks the configuration for supported values. | ||
| func (c Config) Validate() error { | ||
| if _, err := ParseLevel(c.Level); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| switch c.Format { | ||
| case "text", "json": | ||
| default: | ||
| return fmt.Errorf("unsupported format %q", c.Format) | ||
| } | ||
|
|
||
| switch c.Output { | ||
| case "stdout", "stderr", "file", "string": | ||
| default: | ||
| return fmt.Errorf("unsupported output %q", c.Output) | ||
| } | ||
|
|
||
| if c.Output == "file" && strings.TrimSpace(c.FilePath) == "" { | ||
| return fmt.Errorf("file output requires filePath") | ||
| } | ||
| return nil | ||
| } | ||
|
Comment on lines
+61
to
+83
|
||
|
|
||
| func normalizeConfig(cfg Config) (Config, error) { | ||
| cfg = cfg.WithDefaults().Normalize() | ||
| if err := cfg.Validate(); err != nil { | ||
| return Config{}, err | ||
| } | ||
| return cfg, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The serverAddr constant is hardcoded to ":8011". This should be configurable via a command-line flag to allow users to run the server on different ports. Consider adding a --port or --addr flag to the serve command.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot open a new pull request to apply changes based on this feedback