Log fx's startup steps at debug; a refused setting is one line (closes #209)
check / check (push) Canceled after 0s

fx's event lines, errors included, now log at debug level, so a normal
start shows none and DEBUG shows them. RunServer starts and stops the
fx app itself instead of calling Run: when the start fails, it logs the
error's root cause as one "cannot start" line, the backend's last, and
returns 1. Main returns the server's exit code.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-11 20:33:01 +00:00
1 parent 93bbd80d57
commit 0809337c63
5 files changed
+172 -41

No files matched your search

+4
View File
@@ -336,6 +336,10 @@ The backend logs JSON to stdout, or plain text when stdout is a terminal. Each
request is logged with its method, URL, status, client address and duration. request is logged with its method, URL, status, client address and duration.
Every value taken from a request is cut to 128 bytes before it is logged. Every value taken from a request is cut to 128 bytes before it is logged.
The steps `fx` takes to start and stop the backend are logged at debug level, so
they show only with `DEBUG` set. When the backend refuses a setting, its last
log line is one error that names the setting and the reason.
## Design ## Design
The repository holds the page, the backend and the image: The repository holds the page, the backend and the image:
+87 -10
View File
@@ -36,16 +36,94 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) os.Exit(m.Run())
} }
// TestOutputIsJSON: off a terminal, every line the server writes from // TestOutputIsJSONWithoutFxLines: off a terminal, every line the server
// start to stop is JSON, fx's own lines included. // writes from start to stop is JSON, and without DEBUG none of them is
func TestOutputIsJSON(t *testing.T) { // one of fx's.
func TestOutputIsJSONWithoutFxLines(t *testing.T) {
t.Parallel()
stdout, stderr := runServerUntilStopped(t)
requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
if strings.Contains(stdout.String(), `"msg":"started"`) {
t.Fatalf("fx line in stdout without DEBUG:\n%s", stdout)
}
}
// TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as
// JSON.
func TestDebugLogsFxLines(t *testing.T) {
t.Parallel()
stdout, stderr := runServerUntilStopped(t, "DEBUG=1")
requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"started"`) {
t.Fatalf("no fx line in stdout with DEBUG set:\n%s", stdout)
}
}
// TestRefusedSettingIsLastLine: a setting the server refuses stops the
// start, and the last line logged is one error naming the setting and
// why, without fx's text around it.
func TestRefusedSettingIsLastLine(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout) ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel() defer cancel()
child, stdout, stderr := startServer(ctx, t, t.TempDir(),
freePort(ctx, t), "REPORTS_PER_MINUTE=0")
err := child.Wait()
if child.ProcessState.ExitCode() != 1 {
t.Fatalf("server exit = %v, want exit status 1", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want nothing", stderr)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
var last struct {
Level string `json:"level"`
Error string `json:"error"`
}
err = json.Unmarshal([]byte(lines[len(lines)-1]), &last)
if err != nil {
t.Fatalf("last line is not JSON: %v\n%s", err, stdout)
}
want := `REPORTS_PER_MINUTE "0": must be a positive whole number`
if last.Level != "ERROR" || last.Error != want {
t.Fatalf("last line has level %q, error %q; want ERROR, %q\n%s",
last.Level, last.Error, want, stdout)
}
}
// runServerUntilStopped runs main() in a child process with env added
// to its environment, waits until it answers its health check, stops it
// with SIGTERM and returns its stdout and stderr. It fails the test if
// the child does not exit cleanly.
func runServerUntilStopped(
t *testing.T,
env ...string,
) (*bytes.Buffer, *bytes.Buffer) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
port := freePort(ctx, t) port := freePort(ctx, t)
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port) child, stdout, stderr := startServer(ctx, t, t.TempDir(), port, env...)
waitForHealthcheck(ctx, t, port) waitForHealthcheck(ctx, t, port)
@@ -72,11 +150,7 @@ func TestOutputIsJSON(t *testing.T) {
t.Fatalf("server exit = %v, want success", err) t.Fatalf("server exit = %v, want success", err)
} }
requireJSONLines(t, stdout, stderr) return stdout, stderr
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
} }
// TestMalformedConfigFileStopsTheStart: a config file the server finds // TestMalformedConfigFileStopsTheStart: a config file the server finds
@@ -139,7 +213,8 @@ func TestRefusedSentryDSNStopsTheStart(t *testing.T) {
// startServer runs main() in a child process listening on // startServer runs main() in a child process listening on
// 127.0.0.1:port, with home as its HOME and working directory and its // 127.0.0.1:port, with home as its HOME and working directory and its
// data directory in home, so it touches nothing outside home. Its // data directory in home, so it touches nothing outside home. env is
// added to its environment, as NAME=value strings. Its
// stdout and stderr go to the two buffers returned, which hold all of // stdout and stderr go to the two buffers returned, which hold all of
// it once child.Wait returns. The child is killed when ctx ends, and // it once child.Wait returns. The child is killed when ctx ends, and
// killed and reaped when the test ends if nothing waited for it. // killed and reaped when the test ends if nothing waited for it.
@@ -147,6 +222,7 @@ func startServer(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
home, port string, home, port string,
env ...string,
) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) { ) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) {
t.Helper() t.Helper()
@@ -166,6 +242,7 @@ func startServer(
"BIND_ADDRESS=127.0.0.1", "BIND_ADDRESS=127.0.0.1",
"PORT="+port, "PORT="+port,
) )
child.Env = append(child.Env, env...)
child.Stdout = &stdout child.Stdout = &stdout
child.Stderr = &stderr child.Stderr = &stderr
+1 -1
View File
@@ -16,6 +16,7 @@ require (
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/slok/go-http-metrics v0.13.0 github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
) )
@@ -38,7 +39,6 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
+70 -24
View File
@@ -5,8 +5,11 @@
package cli package cli
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log/slog"
"os"
"os/user" "os/user"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
@@ -18,6 +21,7 @@ import (
"sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server" "sneak.berlin/go/netwatch/internal/server"
"go.uber.org/dig"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
) )
@@ -29,19 +33,17 @@ const usageExitCode = 2
// Main runs netwatch-server with args, its command line after the // Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs // program's name, and returns the exit code. With no args, Main runs
// the server by calling serve with appname and version, the name and // the server by calling serve with appname and version, the name and
// version the server logs and reports. With "check-cidr CIDR" or // version the server logs and reports, and returns serve's exit code.
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its // With "check-cidr CIDR" or "prepare-data-dir DATA_DIR" it runs that
// errors to stderr. Any other args get a one-line usage message on // subcommand, which writes its errors to stderr. Any other args get a
// stderr and exit code 2. main passes os.Stderr and RunServer; the // one-line usage message on stderr and exit code 2. main passes
// tests pass a buffer and a function of their own, so that no test // os.Stderr and RunServer; the tests pass a buffer and a function of
// starts the server. When serve is RunServer and the server fails to // their own, so that no test starts the server.
// start, to listen or to stop, fx's Run exits the process with status 1
// and Main never returns.
func Main( func Main(
stderr io.Writer, stderr io.Writer,
appname, version string, appname, version string,
args []string, args []string,
serve func(appname, version string), serve func(appname, version string) int,
) int { ) int {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if // "netwatch-server check-cidr CIDR" exits 1, with the error, if
// this server would refuse CIDR in its TRUSTED_PROXIES. // this server would refuse CIDR in its TRUSTED_PROXIES.
@@ -67,9 +69,7 @@ func Main(
return usageExitCode return usageExitCode
} }
serve(appname, version) return serve(appname, version)
return 0
} }
// checkCIDR returns 0 if the server would take cidr in its // checkCIDR returns 0 if the server would take cidr in its
@@ -109,19 +109,27 @@ func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
} }
// RunServer runs the server until it is told to stop, with appname and // RunServer runs the server until it is told to stop, with appname and
// version as the name and version it logs and reports. If the server // version as the name and version it logs and reports, and returns the
// fails to start, to listen or to stop, fx's Run exits the process // exit code. If the server fails to start, to listen or to stop, it
// with status 1 itself. // returns 1. A failed start logs one line with the error's cause, such
func RunServer(appname, version string) { // as the setting refused and why, as the server's last line.
func RunServer(appname, version string) int {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
fx.New( var log *logger.Logger
// fx logs each step of starting and stopping through the
// server's own logger, so off a terminal those lines are app := fx.New(
// JSON like every other line. // fx logs each step of starting and stopping, errors included,
// at debug level through the server's own logger: they show
// only with DEBUG set, and off a terminal they are JSON like
// every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger { fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
return &fxevent.SlogLogger{Logger: log.Get()} fxLog := &fxevent.SlogLogger{Logger: log.Get()}
fxLog.UseLogLevel(slog.LevelDebug)
fxLog.UseErrorLevel(slog.LevelDebug)
return fxLog
}), }),
fx.Provide( fx.Provide(
config.New, config.New,
@@ -133,11 +141,49 @@ func RunServer(appname, version string) {
reportbuf.New, reportbuf.New,
server.New, server.New,
), ),
// First, so that log is set for the error below even when a
// setting stops the start.
fx.Populate(&log),
fx.Invoke( fx.Invoke(
// First, so the name and version are logged even when // Before the server, so that the name and version are
// a setting stops the start. // logged even when a setting stops the start.
func(log *logger.Logger) { log.Identify() }, func(log *logger.Logger) { log.Identify() },
func(*server.Server) {}, func(*server.Server) {},
), ),
).Run() )
startCtx, cancelStart := context.WithTimeout(
context.Background(), app.StartTimeout())
defer cancelStart()
err := app.Start(startCtx)
if err != nil {
// fx wraps a constructor's error in its own text; the cause
// alone names the setting. log is nil only if fx failed
// before it built the logger.
cause := dig.RootCause(err)
if log == nil {
_, _ = fmt.Fprintln(os.Stderr, cause)
} else {
log.Get().Error("cannot start", "error", cause)
}
return 1
}
// A signal, or the server asking to stop after a listen error.
shutdown := <-app.Wait()
stopCtx, cancelStop := context.WithTimeout(
context.Background(), app.StopTimeout())
defer cancelStop()
err = app.Stop(stopCtx)
if err != nil {
log.Get().Error("cannot stop cleanly", "error", err)
return 1
}
return shutdown.ExitCode
} }
+10 -6
View File
@@ -63,7 +63,7 @@ func TestMainRunsPrepareDataDir(t *testing.T) {
// TestMainRunsServerWithNoArguments: Main given no arguments starts the // TestMainRunsServerWithNoArguments: Main given no arguments starts the
// server with the name and version it was given, writes nothing and // server with the name and version it was given, writes nothing and
// returns 0. // returns the server's exit code.
func TestMainRunsServerWithNoArguments(t *testing.T) { func TestMainRunsServerWithNoArguments(t *testing.T) {
t.Parallel() t.Parallel()
@@ -73,11 +73,13 @@ func TestMainRunsServerWithNoArguments(t *testing.T) {
) )
code := cli.Main(&stderr, "netwatch", "test", []string{}, code := cli.Main(&stderr, "netwatch", "test", []string{},
func(appname, version string) { func(appname, version string) int {
gotAppname, gotVersion = appname, version gotAppname, gotVersion = appname, version
return 3
}) })
if code != 0 || stderr.Len() != 0 { if code != 3 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output", t.Errorf("exit %d, stderr %q; want exit 3 and no output",
code, stderr.String()) code, stderr.String())
} }
@@ -210,10 +212,12 @@ func currentUsername(t *testing.T) string {
// failIfServerStarts is what the tests give Main in place of // failIfServerStarts is what the tests give Main in place of
// cli.RunServer: it fails the test instead of starting the server. // cli.RunServer: it fails the test instead of starting the server.
func failIfServerStarts(t *testing.T) func(appname, version string) { func failIfServerStarts(t *testing.T) func(appname, version string) int {
t.Helper() t.Helper()
return func(string, string) { return func(string, string) int {
t.Error("Main started the server") t.Error("Main started the server")
return 0
} }
} }