🐛 fix: emit all config-loading logs in correct JSON format from the start (#16)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 10s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m14s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped

## Summary

Closes #15

When `logging.json: true` (or `DLC_LOGGING_JSON=true`), the logger was unconditionally initialised to console/text format at the top of `LoadConfig()`, so early log lines — most visibly **"Config file loaded"** — were always written as human-readable text regardless of configuration.

## Root cause

Classic chicken-and-egg: the format flag lives inside the config that is being loaded. The format-switch block only ran *after* `v.Unmarshal()`, too late for the config-file log.

## Changes

### `pkg/config/config.go`
- Add `peekJSONLogging()`: resolves the JSON flag **before** any log is emitted by (1) checking `DLC_LOGGING_JSON` directly via `os.Getenv`, then (2) doing a minimal throwaway Viper pre-read of the config file for the `logging.json` key. This mirrors Viper's own priority order without parsing the full config twice.
- Apply the resolved format immediately and emit **"Logging configured"** as the very first log line.
- Remove the now-redundant format-switch block that ran after `Unmarshal()`.

### `scripts/start-server.sh`, `test-graceful-shutdown.sh`, `test-opentelemetry.sh`
- Replace hardcoded `PROJECT_DIR` path with a dynamic `SCRIPTS_DIR=$(dirname $(realpath ${BASH_SOURCE[0]}))` derivation so scripts work from any worktree or clone location.

## Test plan
- [x] `go test ./pkg/...` — all pass
- [x] `scripts/test-graceful-shutdown.sh` — all JSON valid, all startup logs present
- [x] Manual smoke test: first line is `{"level":"info",...,"message":"Logging configured"}`, every line is valid JSON

Reviewed-on: #16
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #16.
This commit is contained in:
2026-04-12 23:28:35 +02:00
committed by arcodange
parent 5eec64e5e8
commit c17fb4f9b4
9 changed files with 313 additions and 1620 deletions

View File

@@ -118,6 +118,34 @@ type SamplerConfig struct {
Ratio float64 `mapstructure:"ratio"`
}
// peekJSONLogging determines whether JSON logging should be used before the full
// config is loaded, solving the chicken-and-egg problem where the logger format
// must be known before any log is emitted, yet the format is stored in the config.
//
// Resolution order (mirrors Viper's own priority):
// 1. DLC_LOGGING_JSON env var — checked directly via os.Getenv (zero overhead)
// 2. logging.json key in the config file — read with a minimal throwaway Viper
// instance so we don't parse the whole config twice unnecessarily
func peekJSONLogging() bool {
// 1. Env var takes highest priority — check it first
if env := os.Getenv("DLC_LOGGING_JSON"); env != "" {
return strings.EqualFold(env, "true") || env == "1"
}
// 2. Try to read logging.json from the config file
preV := viper.New()
preV.SetDefault("logging.json", false)
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
preV.SetConfigFile(configFile)
} else {
preV.SetConfigName("config")
preV.SetConfigType("yaml")
preV.AddConfigPath(".")
}
_ = preV.ReadInConfig() // ignore errors — defaults apply on failure
return preV.GetBool("logging.json")
}
// LoadConfig loads configuration from file, environment variables, and defaults
// Configuration priority: file > environment variables > defaults
// To specify a custom config file path, set DLC_CONFIG_FILE environment variable
@@ -129,9 +157,17 @@ func LoadConfig() (*Config, error) {
v := viper.New()
// Set up initial console logging for config loading messages
consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr}
log.Logger = log.Output(consoleWriter)
// Configure the logger format before emitting any log output.
// peekJSONLogging reads the JSON setting early (env var + config file pre-read)
// so that every log line — including those produced during config loading — is
// already in the correct format.
jsonLogging := peekJSONLogging()
if jsonLogging {
log.Logger = log.Output(os.Stderr)
} else {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
log.Info().Bool("json", jsonLogging).Msg("Logging configured")
// Set default values
v.SetDefault("server.host", "0.0.0.0")
@@ -227,15 +263,9 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("config unmarshal error: %w", err)
}
// Configure log output format (JSON or console) first
if config.Logging.JSON {
log.Logger = log.Output(os.Stderr)
} else {
consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr}
log.Logger = log.Output(consoleWriter)
}
// Setup logging based on configuration
// Setup logging based on configuration (level, output file, time format).
// The JSON/console format was already applied at the top of LoadConfig via
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
config.SetupLogging()
log.Info().

View File

@@ -33,6 +33,28 @@ import (
//go:embed docs/swagger.json
var swaggerJSON embed.FS
// CancelableContext wraps a context.Context and exposes a Cancel() method so
// that Server.Run() can cancel readiness during graceful shutdown via the type
// assertion it already performs. Callers that don't need controlled cancellation
// (tests, CLI) can pass a plain context.Background() — the assertion silently
// fails and readiness is never explicitly cancelled, which is harmless.
type CancelableContext struct {
context.Context
cancel context.CancelFunc
}
// NewCancelableContext creates a CancelableContext whose Cancel() method will
// be invoked by Server.Run() at the start of graceful shutdown, before the
// 1-second readiness propagation window. The returned CancelFunc is a no-op
// after Cancel() has been called, so it is safe to defer in main.
func NewCancelableContext(parent context.Context) (*CancelableContext, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
return &CancelableContext{Context: ctx, cancel: cancel}, cancel
}
// Cancel satisfies the interface checked in Run() and cancels the context.
func (c *CancelableContext) Cancel() { c.cancel() }
type Server struct {
router *chi.Mux
readyCtx context.Context