Skip to content

Commit

Permalink
feat: support JSON structure log formatting (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
pehlicd authored Aug 29, 2024
1 parent 874932b commit 7053398
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 31 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ variables (or a combination of the two):
| `-host` | `HOST` | Host to listen on | "0.0.0.0" |
| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | |
| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | |
| `-log-format` | `LOG_FORMAT` | Log format (text or json) | "text" |
| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
| `-port` | `PORT` | Port to listen on | 8080 |
Expand Down
35 changes: 25 additions & 10 deletions httpbin/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"flag"
"fmt"
"io"
"log"
"log/slog"
"net"
"net/http"
"os"
Expand All @@ -24,6 +24,7 @@ import (
const (
defaultListenHost = "0.0.0.0"
defaultListenPort = 8080
defaultLogFormat = "text"

// Reasonable defaults for our http server
srvReadTimeout = 5 * time.Second
Expand All @@ -40,8 +41,6 @@ func Main() int {
// mainImpl is the real implementation of Main(), extracted for better
// testability.
func mainImpl(args []string, getEnv func(string) string, getHostname func() (string, error), out io.Writer) int {
logger := log.New(out, "", 0)

cfg, err := loadConfig(args, getEnv, getHostname)
if err != nil {
if cfgErr, ok := err.(ConfigError); ok {
Expand All @@ -67,6 +66,14 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
return 1
}

logger := slog.New(slog.NewTextHandler(out, nil))

if cfg.LogFormat == "json" {
// use structured logging if requested
handler := slog.NewJSONHandler(out, nil)
logger = slog.New(handler)
}

opts := []httpbin.OptionFunc{
httpbin.WithMaxBodySize(cfg.MaxBodySize),
httpbin.WithMaxDuration(cfg.MaxDuration),
Expand All @@ -93,7 +100,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
}

if err := listenAndServeGracefully(srv, cfg, logger); err != nil {
logger.Printf("error: %s", err)
logger.Error(fmt.Sprintf("error: %s", err))
return 1
}

Expand All @@ -113,14 +120,15 @@ type config struct {
RealHostname string
TLSCertFile string
TLSKeyFile string
LogFormat string

// temporary placeholders for arguments that need extra processing
rawAllowedRedirectDomains string
rawUseRealHostname bool
}

// ConfigError is used to signal an error with a command line argument or
// environmment variable.
// environment variable.
//
// It carries the command's usage output, so that we can decouple configuration
// parsing from error reporting for better testability.
Expand Down Expand Up @@ -150,10 +158,11 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
fs.StringVar(&cfg.LogFormat, "log-format", defaultLogFormat, "Log format (text or json)")

// in order to fully control error output whether CLI arguments or env vars
// are used to configure the app, we need to take control away from the
// flagset, which by defaults prints errors automatically.
// flag-set, which by defaults prints errors automatically.
//
// so, we capture the "usage" output it would generate and then trick it
// into generating no output on errors, since they'll be handled by the
Expand Down Expand Up @@ -233,6 +242,12 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
return nil, configErr("https cert and key must both be provided")
}
}
if cfg.LogFormat == defaultLogFormat && getEnv("LOG_FORMAT") != "" {
cfg.LogFormat = getEnv("LOG_FORMAT")
}
if cfg.LogFormat != "text" && cfg.LogFormat != "json" {
return nil, configErr(`invalid log format %q, must be "text" or "json"`, cfg.LogFormat)
}

// useRealHostname will be true if either the `-use-real-hostname`
// arg is given on the command line or if the USE_REAL_HOSTNAME env var
Expand Down Expand Up @@ -263,26 +278,26 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
return cfg, nil
}

func listenAndServeGracefully(srv *http.Server, cfg *config, logger *log.Logger) error {
func listenAndServeGracefully(srv *http.Server, cfg *config, logger *slog.Logger) error {
doneCh := make(chan error, 1)

go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh

logger.Printf("shutting down ...")
logger.Info("shutting down ...")
ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxDuration+1*time.Second)
defer cancel()
doneCh <- srv.Shutdown(ctx)
}()

var err error
if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
logger.Printf("go-httpbin listening on https://%s", srv.Addr)
logger.Info(fmt.Sprintf("go-httpbin listening on https://%s", srv.Addr))
err = srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile)
} else {
logger.Printf("go-httpbin listening on http://%s", srv.Addr)
logger.Info(fmt.Sprintf("go-httpbin listening on http://%s", srv.Addr))
err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
Expand Down
Loading

0 comments on commit 7053398

Please sign in to comment.