Log fx's startup steps at debug; a refused setting is one line (closes #209)
check / check (push) Canceled after 0s
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:
1 parent
93bbd80d57
commit
0809337c63
5 files changed
+172
-41
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user