From dcd5864c16163108aac4400db57e5a02ba69fac9 Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 1 Oct 2026 08:45:30 +0200 Subject: [PATCH 1/3] test: check for go leaks --- config/main_test.go | 11 + keeltest/server.go | 12 + main_test.go | 11 + net/http/middleware/logger_test.go | 2 + net/http/middleware/main_test.go | 11 + .../{gizp_test.go => gzip_test.go} | 0 net/http/roundtripware/main_test.go | 11 + server_port_test.go | 114 ------- server_test.go | 279 +++++++++++++----- service/main_test.go | 11 + 10 files changed, 272 insertions(+), 190 deletions(-) create mode 100644 config/main_test.go create mode 100644 main_test.go create mode 100644 net/http/middleware/main_test.go rename net/http/roundtripware/{gizp_test.go => gzip_test.go} (100%) create mode 100644 net/http/roundtripware/main_test.go delete mode 100644 server_port_test.go create mode 100644 service/main_test.go diff --git a/config/main_test.go b/config/main_test.go new file mode 100644 index 00000000..4f72705b --- /dev/null +++ b/config/main_test.go @@ -0,0 +1,11 @@ +package config_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/keeltest/server.go b/keeltest/server.go index 4f2bad8b..b28071db 100644 --- a/keeltest/server.go +++ b/keeltest/server.go @@ -44,6 +44,8 @@ func NewServer(tb testing.TB, opts ...Option) *Server { opt(inst) } + tb.Cleanup(inst.Close) + return inst } @@ -110,3 +112,13 @@ func (s *Server) Start() { } } } + +// Close closes all registered services. It is registered as cleanup on the +// given testing.TB, but must be called explicitly in examples. +func (s *Server) Close() { + for _, service := range s.services { + if err := service.Close(context.WithoutCancel(s.Context())); err != nil { + s.l.Error("failed to close service", log.FError(err)) + } + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..144254de --- /dev/null +++ b/main_test.go @@ -0,0 +1,11 @@ +package keel_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/net/http/middleware/logger_test.go b/net/http/middleware/logger_test.go index 6a75ad2a..4d8b4e29 100644 --- a/net/http/middleware/logger_test.go +++ b/net/http/middleware/logger_test.go @@ -13,6 +13,7 @@ import ( func ExampleLogger() { svr := keeltest.NewExampleServer() + defer svr.Close() // get logger l := svr.Logger() @@ -44,6 +45,7 @@ func ExampleLogger() { func ExampleLoggerWithInjectLabeler() { svr := keeltest.NewExampleServer() + defer svr.Close() // get logger l := svr.Logger() diff --git a/net/http/middleware/main_test.go b/net/http/middleware/main_test.go new file mode 100644 index 00000000..55dd9a6f --- /dev/null +++ b/net/http/middleware/main_test.go @@ -0,0 +1,11 @@ +package middleware_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/net/http/roundtripware/gizp_test.go b/net/http/roundtripware/gzip_test.go similarity index 100% rename from net/http/roundtripware/gizp_test.go rename to net/http/roundtripware/gzip_test.go diff --git a/net/http/roundtripware/main_test.go b/net/http/roundtripware/main_test.go new file mode 100644 index 00000000..135d8da5 --- /dev/null +++ b/net/http/roundtripware/main_test.go @@ -0,0 +1,11 @@ +package roundtripware_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/server_port_test.go b/server_port_test.go deleted file mode 100644 index c16142ee..00000000 --- a/server_port_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package keel_test - -import ( - "net" - "net/http" - "testing" - "time" - - "github.com/foomo/keel" - "github.com/foomo/keel/service" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" -) - -// TestServerPortInUse covers a pod restarting while its port is still held, e.g. -// after an OOMKill. The server must stop instead of staying alive with a service -// that never got a listener, so the container is restarted rather than reporting -// healthy while serving nothing. -func TestServerPortInUse(t *testing.T) { - t.Parallel() - - l := zaptest.NewLogger(t) - - var lc net.ListenConfig - - occupied, err := lc.Listen(t.Context(), "tcp", "localhost:0") - require.NoError(t, err) - - t.Cleanup(func() { _ = occupied.Close() }) - - svr := keel.NewServer( - keel.WithContext(t.Context()), - keel.WithLogger(l), - keel.WithGracefulPeriod(3*time.Second), - ) - - blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) - svr.AddService(blocked) - - done := make(chan struct{}) - - go func() { - defer close(done) - - svr.Run() - }() - - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("server kept running although a service could not bind its port") - } - - require.Error(t, blocked.Healthz(), "service must not report healthy without a listener") - require.Error(t, svr.Healthz(), "server must not report healthy after it stopped") -} - -// TestServerPortInUseStopsOtherServices asserts the whole server goes down, not -// just the service that failed, so a partially serving pod cannot pass its probes. -func TestServerPortInUseStopsOtherServices(t *testing.T) { - t.Parallel() - - l := zaptest.NewLogger(t) - - var lc net.ListenConfig - - occupied, err := lc.Listen(t.Context(), "tcp", "localhost:0") - require.NoError(t, err) - - t.Cleanup(func() { _ = occupied.Close() }) - - free, err := lc.Listen(t.Context(), "tcp", "localhost:0") - require.NoError(t, err) - - freeAddr := free.Addr().String() - require.NoError(t, free.Close()) - - svr := keel.NewServer( - keel.WithContext(t.Context()), - keel.WithLogger(l), - keel.WithGracefulPeriod(3*time.Second), - ) - - healthy := service.NewHTTP(l, "healthy", freeAddr, http.NewServeMux()) - blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) - svr.AddServices(healthy, blocked) - - done := make(chan struct{}) - - go func() { - defer close(done) - - svr.Run() - }() - - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("server kept running although a service could not bind its port") - } - - dialer := net.Dialer{Timeout: time.Second} - - require.Eventually(t, func() bool { - conn, err := dialer.DialContext(t.Context(), "tcp", freeAddr) - if err != nil { - return true - } - - _ = conn.Close() - - return false - }, 5*time.Second, 50*time.Millisecond, "the remaining service must not keep serving") -} diff --git a/server_test.go b/server_test.go index 2ce317a5..ba6a6072 100644 --- a/server_test.go +++ b/server_test.go @@ -3,42 +3,56 @@ package keel_test import ( "bytes" "context" + "fmt" "io" + "net" "net/http" "strings" "syscall" "testing" "time" + testingx "github.com/foomo/go/testing" "github.com/foomo/keel" "github.com/foomo/keel/service" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.uber.org/zap" "go.uber.org/zap/zaptest" ) +// shutdownSignal is only handled by the suite servers, so sending it does not +// stop servers of tests running in parallel. +const shutdownSignal = syscall.SIGUSR2 + type KeelTestSuite struct { suite.Suite - l *zap.Logger - svr *keel.Server - mux *http.ServeMux - cancel context.CancelFunc -} - -// SetupSuite hook -func (s *KeelTestSuite) SetupSuite() { - s.l = zaptest.NewLogger(s.T()) + l *zap.Logger + svr *keel.Server + mux *http.ServeMux + addr string + zapAddr string + sleeping chan struct{} + done chan struct{} + cancel context.CancelFunc } // BeforeTest hook func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { + ports := testingx.FreePorts(s.T(), 2) + s.addr = fmt.Sprintf("localhost:%d", ports[0]) + s.zapAddr = fmt.Sprintf("localhost:%d", ports[1]) + s.sleeping = make(chan struct{}) + s.done = nil + s.l = zaptest.NewLogger(s.T()) s.mux = http.NewServeMux() s.mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) s.mux.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) { - time.Sleep(time.Second * 1) + close(s.sleeping) + time.Sleep(500 * time.Millisecond) w.WriteHeader(http.StatusOK) }) s.mux.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) { @@ -61,6 +75,7 @@ func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { s.svr = keel.NewServer( keel.WithContext(ctx), keel.WithLogger(s.l), + keel.WithShutdownSignals(shutdownSignal), ) s.cancel = cancel } @@ -68,80 +83,85 @@ func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { // AfterTest hook func (s *KeelTestSuite) AfterTest(suiteName, testName string) { s.cancel() - time.Sleep(time.Second * 3) + s.waitServerStopped() } -// TearDownSuite hook -func (s *KeelTestSuite) TearDownSuite() {} - func (s *KeelTestSuite) TestServiceHTTP() { s.svr.AddServices( - service.NewHTTP(s.l, "test", "localhost:55000", s.mux), + service.NewHTTP(s.l, "test", s.addr, s.mux), ) s.runServer() - if statusCode, _, err := s.httpGet("http://localhost:55000/ok"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/ok")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } } func (s *KeelTestSuite) TestServiceHTTPZap() { s.svr.AddServices( - service.NewHTTPZap(s.l, "zap", "localhost:9100", "/log"), - service.NewHTTP(s.l, "test", "localhost:55000", s.mux), + service.NewHTTPZap(s.l, "zap", s.zapAddr, "/log"), + service.NewHTTP(s.l, "test", s.addr, s.mux), ) s.runServer() + zapURL := "http://" + s.zapAddr + "/log" + + // the zap settings are global, restore them for following runs + defer func() { + _, _, err := s.httpPut(zapURL, `{"level":"info","disableCaller":true,"disableStacktrace":true}`) + s.NoError(err) + }() + s.Run("default", func() { - if statusCode, body, err := s.httpGet("http://localhost:9100/log"); s.NoError(err) { + if statusCode, body, err := s.httpGet(zapURL); s.NoError(err) { s.Equal(http.StatusOK, statusCode) s.JSONEq(`{"level":"info","disableCaller":true,"disableStacktrace":true}`, body) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/info"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/info")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/debug"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/debug")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } }) s.Run("set debug level", func() { - if statusCode, body, err := s.httpPut("http://localhost:9100/log", `{"level":"debug"}`); s.NoError(err) { + if statusCode, body, err := s.httpPut(zapURL, `{"level":"debug"}`); s.NoError(err) { s.Equal(http.StatusOK, statusCode) s.JSONEq(`{"level":"debug","disableCaller":true,"disableStacktrace":true}`, body) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/info"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/info")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/debug"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/debug")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } }) s.Run("enable caller", func() { - if statusCode, body, err := s.httpPut("http://localhost:9100/log", `{"disableCaller":false}`); s.NoError(err) { + if statusCode, body, err := s.httpPut(zapURL, `{"disableCaller":false}`); s.NoError(err) { s.Equal(http.StatusOK, statusCode) s.JSONEq(`{"level":"debug","disableCaller":false,"disableStacktrace":true}`, body) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/error"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/error")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } }) s.Run("enable stacktrace", func() { - if statusCode, body, err := s.httpPut("http://localhost:9100/log", `{"disableStacktrace":false}`); s.NoError(err) { + if statusCode, body, err := s.httpPut(zapURL, `{"disableStacktrace":false}`); s.NoError(err) { s.Equal(http.StatusOK, statusCode) s.JSONEq(`{"level":"debug","disableCaller":false,"disableStacktrace":false}`, body) } - if statusCode, _, err := s.httpGet("http://localhost:55000/log/error"); s.NoError(err) { + if statusCode, _, err := s.httpGet(s.url("/log/error")); s.NoError(err) { s.Equal(http.StatusOK, statusCode) } }) @@ -149,75 +169,81 @@ func (s *KeelTestSuite) TestServiceHTTPZap() { func (s *KeelTestSuite) TestGraceful() { s.svr.AddServices( - service.NewHTTP(s.l, "test", "localhost:55000", s.mux), + service.NewHTTP(s.l, "test", s.addr, s.mux), ) s.runServer() - { // check that we're up - if statusCode, _, err := s.httpGet("http://localhost:55000/ok"); s.NoError(err) { - s.l.Info("received response from /ok") - s.Equal(http.StatusOK, statusCode) - } - } - - { // start long running call in separate process - waitChan := make(chan string) - go func(waitChan chan string) { - waitChan <- "ok" - - s.l.Info("rending request to /sleep") - - if statusCode, _, err := s.httpGet("http://localhost:55000/sleep"); s.NoError(err) { - s.l.Info("received response from /sleep") - s.Equal(http.StatusOK, statusCode) - } - }(waitChan) - - s.l.Info("waiting for ") - <-waitChan + type result struct { + statusCode int + err error } - { - waitChan := make(chan string) - go func(waitChan chan string) { - waitChan <- "ok" - - time.Sleep(time.Second) + // start long running request + sleepResult := make(chan result, 1) - if s.NoError(syscall.Kill(syscall.Getpid(), syscall.SIGINT)) { - s.l.Info("killed myself") - } - }(waitChan) + go func() { + statusCode, _, err := s.httpGet(s.url("/sleep")) + sleepResult <- result{statusCode: statusCode, err: err} + }() - <-waitChan + select { + case <-s.sleeping: + case <-time.After(5 * time.Second): + s.FailNow("request to /sleep did not arrive") } - time.Sleep(time.Second * 3) + // shutdown while the request is in flight + s.Require().NoError(syscall.Kill(syscall.Getpid(), shutdownSignal)) + s.waitServerStopped() - { // check that server is down - _, _, err := s.httpGet("http://localhost:55000/ok") - s.Require().Error(err) + // in flight request must complete + select { + case res := <-sleepResult: + if s.NoError(res.err) { + s.Equal(http.StatusOK, res.statusCode) + } + case <-time.After(5 * time.Second): + s.FailNow("request to /sleep did not complete") } - s.l.Info("done") + // server must be down + _, _, err := s.httpGet(s.url("/ok")) + s.Require().Error(err) } // runServer helper func (s *KeelTestSuite) runServer() { - l := s.svr.Logger() + s.done = make(chan struct{}) - waitChan := make(chan string) - go func(waitChan chan string) { - waitChan <- "finished" + go func(done chan struct{}) { + defer close(done) s.svr.Run() - }(waitChan) + }(s.done) + + s.Require().Eventually(func() bool { + statusCode, _, err := s.httpGet(s.url("/ok")) + return err == nil && statusCode == http.StatusOK + }, 5*time.Second, 10*time.Millisecond, "server did not start") +} + +// waitServerStopped helper +func (s *KeelTestSuite) waitServerStopped() { + if s.done == nil { + return + } + + select { + case <-s.done: + case <-time.After(10 * time.Second): + s.FailNow("server did not stop") + } +} - l.Debug("waiting for server process to start") - <-waitChan - time.Sleep(time.Second) - l.Debug("continuing test") +// url helper +func (s *KeelTestSuite) url(path string) string { + return "http://" + s.addr + path } // httpGet helper @@ -256,3 +282,104 @@ func TestKeelTestSuite(t *testing.T) { t.Parallel() suite.Run(t, new(KeelTestSuite)) } + +// TestServerPortInUse covers a pod restarting while its port is still held, e.g. +// after an OOMKill. The server must stop instead of staying alive with a service +// that never got a listener, so the container is restarted rather than reporting +// healthy while serving nothing. +func TestServerPortInUse(t *testing.T) { + t.Parallel() + + l := zaptest.NewLogger(t) + + var lc net.ListenConfig + + occupied, err := lc.Listen(t.Context(), "tcp", "localhost:0") + require.NoError(t, err) + + t.Cleanup(func() { _ = occupied.Close() }) + + svr := keel.NewServer( + keel.WithContext(t.Context()), + keel.WithLogger(l), + keel.WithGracefulPeriod(3*time.Second), + ) + + blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) + svr.AddService(blocked) + + done := make(chan struct{}) + + go func() { + defer close(done) + + svr.Run() + }() + + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("server kept running although a service could not bind its port") + } + + require.Error(t, blocked.Healthz(), "service must not report healthy without a listener") + require.Error(t, svr.Healthz(), "server must not report healthy after it stopped") +} + +// TestServerPortInUseStopsOtherServices asserts the whole server goes down, not +// just the service that failed, so a partially serving pod cannot pass its probes. +func TestServerPortInUseStopsOtherServices(t *testing.T) { + t.Parallel() + + l := zaptest.NewLogger(t) + + var lc net.ListenConfig + + occupied, err := lc.Listen(t.Context(), "tcp", "localhost:0") + require.NoError(t, err) + + t.Cleanup(func() { _ = occupied.Close() }) + + free, err := lc.Listen(t.Context(), "tcp", "localhost:0") + require.NoError(t, err) + + freeAddr := free.Addr().String() + require.NoError(t, free.Close()) + + svr := keel.NewServer( + keel.WithContext(t.Context()), + keel.WithLogger(l), + keel.WithGracefulPeriod(3*time.Second), + ) + + healthy := service.NewHTTP(l, "healthy", freeAddr, http.NewServeMux()) + blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) + svr.AddServices(healthy, blocked) + + done := make(chan struct{}) + + go func() { + defer close(done) + + svr.Run() + }() + + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("server kept running although a service could not bind its port") + } + + dialer := net.Dialer{Timeout: time.Second} + + require.Eventually(t, func() bool { + conn, err := dialer.DialContext(t.Context(), "tcp", freeAddr) + if err != nil { + return true + } + + _ = conn.Close() + + return false + }, 5*time.Second, 50*time.Millisecond, "the remaining service must not keep serving") +} diff --git a/service/main_test.go b/service/main_test.go new file mode 100644 index 00000000..497c5792 --- /dev/null +++ b/service/main_test.go @@ -0,0 +1,11 @@ +package service_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} From 367c3a130df6dc272002ea05846a7110a16bdd43 Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 1 Oct 2026 09:39:30 +0200 Subject: [PATCH 2/3] test: extend graceful shutdown contract --- export_test.go | 12 +- helper_test.go | 124 ++++++++++++++++++ job_test.go | 42 +++--- server.go | 4 +- server_test.go | 344 ++++++++++++++++++++++++++++++++++++------------- 5 files changed, 422 insertions(+), 104 deletions(-) create mode 100644 helper_test.go diff --git a/export_test.go b/export_test.go index 103885b6..67cffc54 100644 --- a/export_test.go +++ b/export_test.go @@ -1,6 +1,10 @@ package keel -import "context" +import ( + "context" + + "github.com/foomo/keel/healthz" +) // AddPusherForTest exposes the unexported pusher hook so tests can assert that // finalization runs pushers (including on error/interruption) without relying on @@ -13,3 +17,9 @@ func (j *Job) AddPusherForTest(fn func(ctx context.Context) error) { func (j *Job) NameForTest() string { return j.name } + +// ProbesForTest exposes the registered probes so tests can serve them on a +// healthz service with a free port instead of the fixed default address. +func (s *Server) ProbesForTest() map[healthz.Type][]any { + return s.probes() +} diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 00000000..4c6cd090 --- /dev/null +++ b/helper_test.go @@ -0,0 +1,124 @@ +package keel_test + +import ( + "context" + "io" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/foomo/keel" + "github.com/stretchr/testify/require" +) + +// closerCall records a testCloser call and the state of its context. +type closerCall struct { + name string + err error + hasDeadline bool +} + +// testCloser implements interfaces.ErrorCloserWithContext and reports its calls. +type testCloser struct { + name string + calls chan<- closerCall + // block waits for the context to be done before reporting + block bool + // gate, if set, is waited for after reporting + gate <-chan struct{} + err error +} + +func (c *testCloser) Close(ctx context.Context) error { + if c.block { + <-ctx.Done() + } + + _, hasDeadline := ctx.Deadline() + c.calls <- closerCall{name: c.name, err: ctx.Err(), hasDeadline: hasDeadline} + + if c.gate != nil { + <-c.gate + } + + return c.err +} + +type httpResult struct { + statusCode int + err error +} + +// recordCloser implements interfaces.ErrorCloserWithContext and records calls. +type recordCloser struct { + closed atomic.Bool +} + +func (c *recordCloser) Close(_ context.Context) error { + c.closed.Store(true) + return nil +} + +func httpGet(ctx context.Context, url string) httpResult { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return httpResult{err: err} + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return httpResult{err: err} + } + + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + return httpResult{statusCode: resp.StatusCode} +} + +// startServer runs the server and returns a channel closed once Run returned. +// The server is shut down on cleanup in case the test did not stop it. +func startServer(t *testing.T, svr *keel.Server) <-chan struct{} { + t.Helper() + + done := make(chan struct{}) + + go func() { + defer close(done) + + svr.Run() + }() + + t.Cleanup(func() { + svr.ShutdownCancel()() + receive(t, done, "server did not stop on cleanup") + }) + + return done +} + +func requireEventuallyStatus(t *testing.T, url string, statusCode int) { + t.Helper() + + require.Eventually(t, func() bool { + res := httpGet(t.Context(), url) + return res.err == nil && res.statusCode == statusCode + }, 5*time.Second, 10*time.Millisecond, "%s did not respond with %d", url, statusCode) +} + +// receive returns the next value of ch, failing t if none arrives in time. +func receive[T any](t *testing.T, ch <-chan T, msg string) T { + t.Helper() + + select { + case v := <-ch: + return v + case <-time.After(5 * time.Second): + t.Fatal(msg) + } + + var zero T + + return zero +} diff --git a/job_test.go b/job_test.go index c1c9b5b3..7e52b3c2 100644 --- a/job_test.go +++ b/job_test.go @@ -14,16 +14,6 @@ import ( "go.uber.org/zap/zaptest" ) -// recordCloser implements interfaces.ErrorCloserWithContext and records calls. -type recordCloser struct { - closed atomic.Bool -} - -func (c *recordCloser) Close(_ context.Context) error { - c.closed.Store(true) - return nil -} - func TestJob_StepsRunInOrder(t *testing.T) { t.Parallel() @@ -93,19 +83,41 @@ func TestJob_ContextCancelInterrupts(t *testing.T) { keel.JobWithContext(ctx), ) j.AddStep("blocking", func(stepCtx context.Context, _ *zap.Logger) error { + cancel() <-stepCtx.Done() + return stepCtx.Err() }) - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - err := j.RunE() require.ErrorIs(t, err, context.Canceled) } +// TestJob_FinalizeAfterContextCancel asserts finalization is detached from the +// cancelled job context, so closers can still flush within the graceful period. +func TestJob_FinalizeAfterContextCancel(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + calls := make(chan closerCall, 1) + + j := keel.NewJob( + keel.JobWithLogger(zaptest.NewLogger(t)), + keel.JobWithContext(ctx), + keel.JobWithCloser(&testCloser{name: "closer", calls: calls}), + ) + j.AddStep("cancel", func(_ context.Context, _ *zap.Logger) error { + cancel() + return nil + }) + + _ = j.RunE() + + call := receive(t, calls, "closer was not called") + require.NoError(t, call.err, "closer must not receive a cancelled context") + require.True(t, call.hasDeadline, "closer context must be bounded by the graceful period") +} + func TestJob_FinalizeRunsClosersAndPushersOnError(t *testing.T) { t.Parallel() diff --git a/server.go b/server.go index e94779bf..3e822349 100644 --- a/server.go +++ b/server.go @@ -105,7 +105,9 @@ func NewServer(opts ...Option) *Server { <-inst.gracefulCtx.Done() inst.shutdown.Store(true) - timeoutCtx, timeoutCancel := context.WithTimeout(inst.ctx, inst.gracefulPeriod) + // detach from the server context so closers get the full graceful period + // even if the server context has been canceled + timeoutCtx, timeoutCancel := context.WithTimeout(context.WithoutCancel(inst.ctx), inst.gracefulPeriod) defer timeoutCancel() inst.l.Info("keel closer closed", diff --git a/server_test.go b/server_test.go index ba6a6072..bbc87e4e 100644 --- a/server_test.go +++ b/server_test.go @@ -3,11 +3,13 @@ package keel_test import ( "bytes" "context" + "errors" "fmt" "io" "net" "net/http" "strings" + "sync" "syscall" "testing" "time" @@ -21,20 +23,19 @@ import ( "go.uber.org/zap/zaptest" ) -// shutdownSignal is only handled by the suite servers, so sending it does not +// shutdownSignal is only registered by the signal trigger, so sending it does not // stop servers of tests running in parallel. const shutdownSignal = syscall.SIGUSR2 type KeelTestSuite struct { suite.Suite - l *zap.Logger - svr *keel.Server - mux *http.ServeMux - addr string - zapAddr string - sleeping chan struct{} - done chan struct{} - cancel context.CancelFunc + l *zap.Logger + svr *keel.Server + mux *http.ServeMux + addr string + zapAddr string + done chan struct{} + cancel context.CancelFunc } // BeforeTest hook @@ -42,7 +43,6 @@ func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { ports := testingx.FreePorts(s.T(), 2) s.addr = fmt.Sprintf("localhost:%d", ports[0]) s.zapAddr = fmt.Sprintf("localhost:%d", ports[1]) - s.sleeping = make(chan struct{}) s.done = nil s.l = zaptest.NewLogger(s.T()) @@ -50,11 +50,6 @@ func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { s.mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - s.mux.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) { - close(s.sleeping) - time.Sleep(500 * time.Millisecond) - w.WriteHeader(http.StatusOK) - }) s.mux.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) { panic("foobar") }) @@ -75,7 +70,6 @@ func (s *KeelTestSuite) BeforeTest(suiteName, testName string) { s.svr = keel.NewServer( keel.WithContext(ctx), keel.WithLogger(s.l), - keel.WithShutdownSignals(shutdownSignal), ) s.cancel = cancel } @@ -167,51 +161,6 @@ func (s *KeelTestSuite) TestServiceHTTPZap() { }) } -func (s *KeelTestSuite) TestGraceful() { - s.svr.AddServices( - service.NewHTTP(s.l, "test", s.addr, s.mux), - ) - - s.runServer() - - type result struct { - statusCode int - err error - } - - // start long running request - sleepResult := make(chan result, 1) - - go func() { - statusCode, _, err := s.httpGet(s.url("/sleep")) - sleepResult <- result{statusCode: statusCode, err: err} - }() - - select { - case <-s.sleeping: - case <-time.After(5 * time.Second): - s.FailNow("request to /sleep did not arrive") - } - - // shutdown while the request is in flight - s.Require().NoError(syscall.Kill(syscall.Getpid(), shutdownSignal)) - s.waitServerStopped() - - // in flight request must complete - select { - case res := <-sleepResult: - if s.NoError(res.err) { - s.Equal(http.StatusOK, res.statusCode) - } - case <-time.After(5 * time.Second): - s.FailNow("request to /sleep did not complete") - } - - // server must be down - _, _, err := s.httpGet(s.url("/ok")) - s.Require().Error(err) -} - // runServer helper func (s *KeelTestSuite) runServer() { s.done = make(chan struct{}) @@ -283,6 +232,249 @@ func TestKeelTestSuite(t *testing.T) { suite.Run(t, new(KeelTestSuite)) } +// TestServerGracefulShutdown asserts the graceful shutdown contract for every +// trigger: readiness fails and new connections are refused while in-flight +// requests complete with a live context, then Run returns. +func TestServerGracefulShutdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []keel.Option + trigger func(t *testing.T, svr *keel.Server, fail chan<- error) + }{ + { + name: "shutdown cancel", + trigger: func(t *testing.T, svr *keel.Server, _ chan<- error) { + t.Helper() + svr.ShutdownCancel()() + }, + }, + { + name: "signal", + opts: []keel.Option{keel.WithShutdownSignals(shutdownSignal)}, + trigger: func(t *testing.T, _ *keel.Server, _ chan<- error) { + t.Helper() + require.NoError(t, syscall.Kill(syscall.Getpid(), shutdownSignal)) + }, + }, + { + name: "service failure", + trigger: func(t *testing.T, _ *keel.Server, fail chan<- error) { + t.Helper() + + fail <- errors.New("boom") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l := zaptest.NewLogger(t) + ports := testingx.FreePorts(t, 2) + appURL := fmt.Sprintf("http://localhost:%d", ports[0]) + readinessURL := fmt.Sprintf("http://localhost:%d/healthz/readiness", ports[1]) + + entered := make(chan struct{}) + release := make(chan struct{}) + handlerCtxErr := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {}) + mux.HandleFunc("/block", func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-release + + handlerCtxErr <- r.Context().Err() + }) + + svr := keel.NewServer(append([]keel.Option{ + keel.WithContext(t.Context()), + keel.WithLogger(l), + keel.WithGracefulPeriod(5 * time.Second), + }, tt.opts...)...) + + fail := make(chan error, 1) + gate := make(chan struct{}) + gateCalls := make(chan closerCall, 1) + + // closers run in registration order: the gate holds the shutdown before + // any service is closed, and healthz is added last so that readiness + // stays observable while the app service drains + svr.AddCloser(&testCloser{name: "gate", calls: gateCalls, gate: gate}) + svr.AddServices( + service.NewHTTP(l, "app", fmt.Sprintf("localhost:%d", ports[0]), mux), + service.NewGoRoutine(l, "worker", func(ctx context.Context, _ *zap.Logger) error { + select { + case err := <-fail: + return err + case <-ctx.Done(): + return nil + } + }), + service.NewHealthz(l, "healthz", fmt.Sprintf("localhost:%d", ports[1]), "/healthz", svr.ProbesForTest()), + ) + + done := startServer(t, svr) + + // unblock on failure, so the server can drain on cleanup + unblock := sync.OnceFunc(func() { close(release) }) + t.Cleanup(unblock) + + openGate := sync.OnceFunc(func() { close(gate) }) + t.Cleanup(openGate) + + requireEventuallyStatus(t, appURL+"/ok", http.StatusOK) + requireEventuallyStatus(t, readinessURL, http.StatusOK) + + // start in-flight request + inFlight := make(chan httpResult, 1) + + go func() { inFlight <- httpGet(t.Context(), appURL+"/block") }() + + receive(t, entered, "request did not reach the handler") + + tt.trigger(t, svr, fail) + + // no service is closed yet: readiness must fail before the listeners close + receive(t, gateCalls, "shutdown did not start") + requireEventuallyStatus(t, readinessURL, http.StatusServiceUnavailable) + requireEventuallyStatus(t, appURL+"/ok", http.StatusOK) + + openGate() + + require.Eventually(t, func() bool { + return httpGet(t.Context(), appURL+"/ok").err != nil + }, 5*time.Second, 10*time.Millisecond, "new connections must be refused while draining") + + select { + case <-done: + t.Fatal("server stopped before the in-flight request completed") + default: + } + + unblock() + + require.NoError(t, receive(t, handlerCtxErr, "handler did not complete"), "in-flight request context must stay alive") + + res := receive(t, inFlight, "in-flight request did not complete") + require.NoError(t, res.err) + require.Equal(t, http.StatusOK, res.statusCode) + + receive(t, done, "server did not stop") + require.ErrorIs(t, svr.Healthz(), keel.ErrServerNotRunning) + }) + } +} + +// TestServerShutdownClosers asserts closers run in registration order and a +// failing closer does not prevent the remaining ones from running. +func TestServerShutdownClosers(t *testing.T) { + t.Parallel() + + calls := make(chan closerCall, 3) + + svr := keel.NewServer( + keel.WithContext(t.Context()), + keel.WithLogger(zaptest.NewLogger(t)), + ) + svr.AddClosers( + &testCloser{name: "first", calls: calls}, + &testCloser{name: "failing", calls: calls, err: errors.New("boom")}, + &testCloser{name: "last", calls: calls}, + ) + + done := startServer(t, svr) + + svr.ShutdownCancel()() + receive(t, done, "server did not stop") + + for _, name := range []string{"first", "failing", "last"} { + require.Equal(t, name, receive(t, calls, "closer was not called").name) + } +} + +// TestServerGracefulPeriodExceeded asserts the graceful period bounds the +// shutdown: a hanging closer is given up on and the remaining closers still run. +func TestServerGracefulPeriodExceeded(t *testing.T) { + t.Parallel() + + calls := make(chan closerCall, 2) + + svr := keel.NewServer( + keel.WithContext(t.Context()), + keel.WithLogger(zaptest.NewLogger(t)), + keel.WithGracefulPeriod(100*time.Millisecond), + ) + svr.AddClosers( + &testCloser{name: "hanging", calls: calls, block: true}, + &testCloser{name: "last", calls: calls}, + ) + + done := startServer(t, svr) + + svr.ShutdownCancel()() + receive(t, done, "server did not stop after the graceful period") + + hanging := receive(t, calls, "hanging closer was not called") + require.Equal(t, "hanging", hanging.name) + require.ErrorIs(t, hanging.err, context.DeadlineExceeded) + require.Equal(t, "last", receive(t, calls, "last closer was not called").name) +} + +// TestServerContextCancel asserts cancelling the server context is a hard stop: +// in-flight requests see their context cancelled, but closers still get a live +// context bounded by the graceful period, e.g. to flush telemetry. +func TestServerContextCancel(t *testing.T) { + t.Parallel() + + l := zaptest.NewLogger(t) + addr := fmt.Sprintf("localhost:%d", testingx.FreePort(t)) + ctx, cancel := context.WithCancel(t.Context()) + + entered := make(chan struct{}) + handlerCtxErr := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {}) + mux.HandleFunc("/block", func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-r.Context().Done() + + handlerCtxErr <- r.Context().Err() + }) + + calls := make(chan closerCall, 1) + + svr := keel.NewServer( + keel.WithContext(ctx), + keel.WithLogger(l), + keel.WithGracefulPeriod(5*time.Second), + ) + svr.AddService(service.NewHTTP(l, "app", addr, mux)) + svr.AddCloser(&testCloser{name: "closer", calls: calls}) + + done := startServer(t, svr) + + requireEventuallyStatus(t, "http://"+addr+"/ok", http.StatusOK) + + go func() { _ = httpGet(t.Context(), "http://"+addr+"/block") }() + + receive(t, entered, "request did not reach the handler") + + cancel() + + require.ErrorIs(t, receive(t, handlerCtxErr, "handler did not complete"), context.Canceled) + + call := receive(t, calls, "closer was not called") + require.NoError(t, call.err, "closer must not receive a cancelled context") + require.True(t, call.hasDeadline, "closer context must be bounded by the graceful period") + + receive(t, done, "server did not stop") +} + // TestServerPortInUse covers a pod restarting while its port is still held, e.g. // after an OOMKill. The server must stop instead of staying alive with a service // that never got a listener, so the container is restarted rather than reporting @@ -308,19 +500,8 @@ func TestServerPortInUse(t *testing.T) { blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) svr.AddService(blocked) - done := make(chan struct{}) - - go func() { - defer close(done) - - svr.Run() - }() - - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("server kept running although a service could not bind its port") - } + done := startServer(t, svr) + receive(t, done, "server kept running although a service could not bind its port") require.Error(t, blocked.Healthz(), "service must not report healthy without a listener") require.Error(t, svr.Healthz(), "server must not report healthy after it stopped") @@ -356,19 +537,8 @@ func TestServerPortInUseStopsOtherServices(t *testing.T) { blocked := service.NewHTTP(l, "blocked", occupied.Addr().String(), http.NewServeMux()) svr.AddServices(healthy, blocked) - done := make(chan struct{}) - - go func() { - defer close(done) - - svr.Run() - }() - - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("server kept running although a service could not bind its port") - } + done := startServer(t, svr) + receive(t, done, "server kept running although a service could not bind its port") dialer := net.Dialer{Timeout: time.Second} From 7a54cdae822908a4ce7e9f969055c6d168a589de Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 1 Oct 2026 09:50:27 +0200 Subject: [PATCH 3/3] docs: update/add go docs --- closer.go | 3 + config/config.go | 85 +++++++++++- config/doc.go | 27 ++++ config/generic.go | 19 ++- config/readme.go | 5 +- config/remote.go | 4 + config/watch.go | 21 ++- doc.go | 63 +++++++++ env/doc.go | 12 ++ env/env.go | 54 +++++--- env/readme.go | 4 +- errors.go | 5 +- examples/config/doc.go | 6 + examples/config/main.go | 2 + examples/graceful/doc.go | 6 + examples/healthz/doc.go | 6 + examples/healthz/handler/doc.go | 2 + examples/healthz/handler/handler.go | 3 + examples/job/doc.go | 5 + examples/logging/doc.go | 5 + examples/logging/main.go | 2 + examples/middlewares/basicauth/doc.go | 5 + examples/middlewares/cors/doc.go | 6 + examples/middlewares/jwtfromcookie/doc.go | 6 + examples/middlewares/jwtfromcookie/main.go | 2 + examples/middlewares/jwtfromtoken/doc.go | 7 + examples/middlewares/logger/doc.go | 4 + examples/middlewares/recover/doc.go | 5 + examples/middlewares/requestid/doc.go | 5 + examples/middlewares/responsetime/doc.go | 6 + examples/middlewares/sessionid/doc.go | 5 + examples/middlewares/skip/doc.go | 6 + examples/middlewares/telemetry/doc.go | 5 + .../middlewares/tokenauthfromcookie/doc.go | 6 + .../middlewares/tokenauthfromheader/doc.go | 6 + examples/persistence/mongo/doc.go | 6 + examples/persistence/mongo/repository/doc.go | 2 + .../persistence/mongo/repository/dummy.go | 8 +- examples/persistence/mongo/store/codec.go | 5 + examples/persistence/mongo/store/datetime.go | 12 +- examples/persistence/mongo/store/doc.go | 3 + examples/persistence/mongo/store/dummy.go | 1 + examples/persistence/mongo/store/entity.go | 8 +- .../mongo/store/entitywithtimestamps.go | 9 +- .../mongo/store/entitywithversions.go | 9 +- examples/persistence/postgres/doc.go | 8 ++ .../persistence/postgres/repository/doc.go | 2 + .../persistence/postgres/repository/dummy.go | 6 +- examples/remoteconfig/doc.go | 6 + examples/roundtripwares/logger/doc.go | 5 + examples/roundtripwares/requestid/doc.go | 5 + examples/roundtripwares/retry/doc.go | 5 + examples/serviceenabler/doc.go | 6 + examples/services/doc.go | 6 + examples/stream/jetstream/doc.go | 6 + examples/stream/jetstreamraw/doc.go | 6 + examples/telemetry/doc.go | 6 + healthz.go | 4 + healthz/doc.go | 10 ++ healthz/docs.go | 1 - healthz/healthzer.go | 15 ++- healthz/type.go | 14 +- integration/gotsrpc/doc.go | 8 ++ integration/gotsrpc/gorpcclient.go | 1 + integration/gotsrpc/gorpcclientservice.go | 5 + integration/gotsrpc/gorpcproxy.go | 1 + integration/gotsrpc/gorpcproxyservice.go | 6 + integration/gotsrpc/net/middleware/doc.go | 9 ++ .../gotsrpc/net/middleware/telemetry.go | 46 +++++-- integration/nats/connect.go | 10 +- integration/nats/doc.go | 17 +++ integration/nats/jetstream.go | 9 +- integration/nats/service/doc.go | 7 + integration/nats/service/embeddedserver.go | 18 +++ integration/temporal/activityoptions.go | 16 +++ integration/temporal/client.go | 33 ++++- integration/temporal/doc.go | 22 ++++ integration/temporal/error.go | 23 ++++ integration/temporal/log.go | 8 ++ integration/temporal/logger.go | 12 ++ integration/temporal/metrics.go | 18 +++ integration/temporal/service.go | 6 + integration/temporal/testservice.go | 8 ++ internal/otel/doc.go | 3 + internal/otel/errorhandler.go | 9 ++ internal/otel/exporters/zap/doc.go | 3 + internal/otel/exporters/zap/exporter.go | 16 ++- internal/otel/logger.go | 12 ++ internal/pyroscope/doc.go | 2 + internal/pyroscope/logger.go | 5 + internal/sync/bufpool.go | 1 + internal/sync/doc.go | 4 + job.go | 27 ++-- joboption.go | 69 ++++++---- jwt/doc.go | 13 ++ jwt/jwt.go | 24 +++- jwt/jwtclaims.go | 30 +++-- jwt/jwtkey.go | 22 +++- jwt/keyfunc.go | 4 + keeltest/assert/assert.go | 8 ++ keeltest/assert/assertions.go | 8 +- keeltest/assert/doc.go | 4 + keeltest/client.go | 11 ++ keeltest/doc.go | 20 +++ keeltest/inline.go | 16 +++ keeltest/jwt.go | 3 +- keeltest/option.go | 14 +- keeltest/require/assertions.go | 8 +- keeltest/require/doc.go | 4 + keeltest/require/require.go | 8 ++ keeltest/server.go | 27 ++-- keeltest/service.go | 3 +- keeltest/servicehttp.go | 13 +- ldflags.go | 13 +- log/configure.go | 8 ++ log/doc.go | 21 +++ log/fields.go | 16 +-- log/fields_code.go | 32 +++-- log/fields_duration.go | 12 +- log/fields_error.go | 7 + log/fields_http.go | 120 ++++++++++++----- log/fields_messaging.go | 92 +++++++++---- log/fields_net.go | 16 ++- log/fields_service.go | 56 ++++++-- log/fields_stream.go | 16 ++- log/fields_trace.go | 3 + log/labeler.go | 12 +- log/logger.go | 29 ++++- log/must.go | 4 +- log/otel.go | 6 + log/with.go | 32 +++++ markdown/doc.go | 3 + markdown/markdown.go | 11 +- markdown/utils.go | 3 + metrics/doc.go | 6 + metrics/metrics.go | 24 +++- metrics/metrics_http.go | 20 ++- metrics/readme.go | 5 +- net/http/client.go | 121 +++++++++++++----- net/http/context/contextkey.go | 2 + net/http/context/doc.go | 9 ++ net/http/context/referrer.go | 4 + net/http/context/requestid.go | 4 + net/http/context/sessionid.go | 4 + net/http/context/trackingid.go | 4 + net/http/cookie/cookie.go | 41 ++++-- net/http/cookie/doc.go | 17 +++ net/http/cookie/domainprovider.go | 20 ++- net/http/cookie/timeprovider.go | 10 ++ net/http/doc.go | 23 ++++ net/http/header.go | 4 +- net/http/headervalues.go | 2 + net/http/log/doc.go | 4 + net/http/log/labeler.go | 10 ++ net/http/middleware.go | 6 +- net/http/middleware/basicauth.go | 16 ++- net/http/middleware/cookietokenprovider.go | 9 +- net/http/middleware/cors.go | 56 +++++--- net/http/middleware/doc.go | 32 +++++ net/http/middleware/gzip.go | 16 ++- net/http/middleware/headertokenprovider.go | 16 ++- net/http/middleware/jwt.go | 80 ++++++++---- net/http/middleware/logger.go | 34 +++-- net/http/middleware/poweredbyheader.go | 20 ++- net/http/middleware/rbac.go | 4 +- net/http/middleware/recover.go | 15 ++- net/http/middleware/referrer.go | 21 ++- net/http/middleware/requestid.go | 48 +++++-- .../middleware/requesturiblacklistskipper.go | 3 +- .../middleware/requesturiwhitelistskipper.go | 4 +- net/http/middleware/responsetime.go | 30 +++-- net/http/middleware/responsewriter.go | 15 ++- net/http/middleware/serverheader.go | 19 ++- net/http/middleware/sessionid.go | 50 +++++--- net/http/middleware/skip.go | 2 + net/http/middleware/skipper.go | 1 + net/http/middleware/telemetry.go | 27 +++- net/http/middleware/tokenauth.go | 16 ++- net/http/middleware/tokenprovider.go | 2 + net/http/middleware/trackingid.go | 50 +++++--- net/http/provider/doc.go | 3 + net/http/provider/requestidprovider.go | 3 +- net/http/rbac/compiledrule.go | 2 +- net/http/rbac/decision.go | 4 +- net/http/rbac/doc.go | 28 ++++ net/http/rbac/helper.go | 9 +- net/http/rbac/matcher.go | 9 +- net/http/rbac/outcome.go | 17 ++- net/http/roundtripware/circuitbreaker.go | 46 ++++--- net/http/roundtripware/doc.go | 23 ++++ net/http/roundtripware/dump.go | 8 +- net/http/roundtripware/gzip.go | 15 ++- net/http/roundtripware/helper.go | 8 +- net/http/roundtripware/labeler.go | 8 +- net/http/roundtripware/logger.go | 31 +++-- net/http/roundtripware/metric.go | 6 +- net/http/roundtripware/recover.go | 14 +- net/http/roundtripware/referer.go | 11 +- net/http/roundtripware/requestid.go | 20 ++- net/http/roundtripware/retry.go | 20 ++- net/http/roundtripware/roundtripper.go | 2 + net/http/roundtripware/roundtripware.go | 11 +- net/http/roundtripware/sessionid.go | 15 ++- net/http/roundtripware/trackingid.go | 16 ++- net/http/server.go | 9 +- net/stream/doc.go | 5 + net/stream/jetstream/doc.go | 23 ++++ net/stream/jetstream/publisher.go | 15 +++ net/stream/jetstream/readme.go | 8 ++ net/stream/jetstream/stream.go | 56 ++++++-- net/stream/jetstream/subscriber.go | 18 +++ net/stream/middleware.go | 3 + option.go | 99 +++++++++----- persistence/distributedrwlock.go | 14 +- persistence/doc.go | 8 ++ persistence/error.go | 5 +- persistence/mongo/collection.go | 78 ++++++++++- persistence/mongo/distributedrwlock.go | 18 ++- persistence/mongo/doc.go | 29 +++++ persistence/mongo/entity.go | 6 + persistence/mongo/persistor.go | 54 ++++++-- persistence/mongo/readme.go | 3 + persistence/mongo/utils.go | 3 +- persistence/postgres/doc.go | 10 ++ persistence/postgres/persistor.go | 25 +++- runtime.go | 4 +- semconv/debug.go | 2 +- semconv/doc.go | 10 ++ semconv/gotsrpc.go | 1 + semconv/gotsrpcconv/doc.go | 7 + semconv/gotsrpcconv/metric.go | 16 ++- server.go | 80 ++++++++---- service.go | 6 +- service/doc.go | 19 +++ service/errors.go | 6 +- service/goroutine.go | 22 +++- service/http.go | 15 ++- service/httphealthz.go | 28 +++- service/httppprof.go | 7 + service/httpprometheus.go | 7 + service/httpreadme.go | 12 +- service/httpviper.go | 7 + service/httpzap.go | 8 ++ service/subscription.go | 17 ++- serviceenabler.go | 17 +++ servicefn.go | 2 + servicefunc.go | 2 + telemetry/autoconfig.go | 11 +- telemetry/code.go | 5 + telemetry/config.go | 8 +- telemetry/context.go | 61 +++++---- telemetry/deprecated.go | 5 +- telemetry/doc.go | 63 +++++++++ telemetry/logger.go | 8 ++ telemetry/loggerprovider.go | 27 +++- telemetry/meter.go | 6 +- telemetry/meterprovider.go | 20 ++- telemetry/profiler.go | 7 + telemetry/pyroscope.go | 2 + telemetry/resource.go | 7 +- telemetry/span.go | 44 ++++--- telemetry/telemetrytest/doc.go | 3 + telemetry/telemetrytest/tracer.go | 2 + telemetry/traceprovider.go | 11 +- telemetry/tracer.go | 6 +- time/doc.go | 7 + time/time.go | 19 ++- utils/net/http/basicauth.go | 2 + utils/net/http/doc.go | 12 ++ utils/net/http/errors.go | 90 +++++++------ utils/net/http/request.go | 11 +- 271 files changed, 3443 insertions(+), 806 deletions(-) create mode 100644 config/doc.go create mode 100644 doc.go create mode 100644 env/doc.go create mode 100644 examples/config/doc.go create mode 100644 examples/graceful/doc.go create mode 100644 examples/healthz/doc.go create mode 100644 examples/healthz/handler/doc.go create mode 100644 examples/job/doc.go create mode 100644 examples/logging/doc.go create mode 100644 examples/middlewares/basicauth/doc.go create mode 100644 examples/middlewares/cors/doc.go create mode 100644 examples/middlewares/jwtfromcookie/doc.go create mode 100644 examples/middlewares/jwtfromtoken/doc.go create mode 100644 examples/middlewares/logger/doc.go create mode 100644 examples/middlewares/recover/doc.go create mode 100644 examples/middlewares/requestid/doc.go create mode 100644 examples/middlewares/responsetime/doc.go create mode 100644 examples/middlewares/sessionid/doc.go create mode 100644 examples/middlewares/skip/doc.go create mode 100644 examples/middlewares/telemetry/doc.go create mode 100644 examples/middlewares/tokenauthfromcookie/doc.go create mode 100644 examples/middlewares/tokenauthfromheader/doc.go create mode 100644 examples/persistence/mongo/doc.go create mode 100644 examples/persistence/mongo/repository/doc.go create mode 100644 examples/persistence/mongo/store/doc.go create mode 100644 examples/persistence/postgres/doc.go create mode 100644 examples/persistence/postgres/repository/doc.go create mode 100644 examples/remoteconfig/doc.go create mode 100644 examples/roundtripwares/logger/doc.go create mode 100644 examples/roundtripwares/requestid/doc.go create mode 100644 examples/roundtripwares/retry/doc.go create mode 100644 examples/serviceenabler/doc.go create mode 100644 examples/services/doc.go create mode 100644 examples/stream/jetstream/doc.go create mode 100644 examples/stream/jetstreamraw/doc.go create mode 100644 examples/telemetry/doc.go create mode 100644 healthz/doc.go delete mode 100644 healthz/docs.go create mode 100644 integration/gotsrpc/doc.go create mode 100644 integration/gotsrpc/net/middleware/doc.go create mode 100644 integration/nats/doc.go create mode 100644 integration/nats/service/doc.go create mode 100644 integration/temporal/doc.go create mode 100644 internal/otel/doc.go create mode 100644 internal/otel/exporters/zap/doc.go create mode 100644 internal/pyroscope/doc.go create mode 100644 internal/sync/doc.go create mode 100644 jwt/doc.go create mode 100644 keeltest/assert/doc.go create mode 100644 keeltest/doc.go create mode 100644 keeltest/require/doc.go create mode 100644 log/doc.go create mode 100644 markdown/doc.go create mode 100644 metrics/doc.go create mode 100644 net/http/context/doc.go create mode 100644 net/http/cookie/doc.go create mode 100644 net/http/doc.go create mode 100644 net/http/log/doc.go create mode 100644 net/http/middleware/doc.go create mode 100644 net/http/provider/doc.go create mode 100644 net/http/rbac/doc.go create mode 100644 net/http/roundtripware/doc.go create mode 100644 net/stream/doc.go create mode 100644 net/stream/jetstream/doc.go create mode 100644 persistence/doc.go create mode 100644 persistence/mongo/doc.go create mode 100644 persistence/postgres/doc.go create mode 100644 semconv/doc.go create mode 100644 semconv/gotsrpcconv/doc.go create mode 100644 service/doc.go create mode 100644 telemetry/doc.go create mode 100644 telemetry/telemetrytest/doc.go create mode 100644 time/doc.go create mode 100644 utils/net/http/doc.go diff --git a/closer.go b/closer.go index 06adcf1e..99d48883 100644 --- a/closer.go +++ b/closer.go @@ -65,6 +65,9 @@ func closeAll(ctx context.Context, l *zap.Logger, closers []any) { } } +// IsCloser reports whether v implements any of the closer interfaces declared +// in [github.com/foomo/keel/interfaces] (Closer, Shutdowner, Stopper or +// Unsubscriber in any of their error or context variants). func IsCloser(v any) bool { switch v.(type) { case interfaces.Closer, diff --git a/config/config.go b/config/config.go index 9939062c..293facdf 100644 --- a/config/config.go +++ b/config/config.go @@ -9,7 +9,9 @@ import ( "github.com/spf13/viper" ) -// config holds the global configuration +// Package-level registry state. config is the global [viper.Viper] instance +// used whenever a nil instance is passed; requiredKeys, defaults and types +// record every key registered through the getter constructors. var ( config *viper.Viper requiredKeys []string @@ -17,7 +19,8 @@ var ( types = map[string]string{} ) -// Init sets up the configuration +// init creates the global viper instance with automatic environment lookup, +// YAML config type and "." to "_" env key replacement. func init() { config = viper.New() config.AutomaticEnv() @@ -25,11 +28,13 @@ func init() { config.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) } -// Config return the config instance +// Config returns the global [viper.Viper] instance. func Config() *viper.Viper { return config } +// GetBool registers key with fallback as its default on c and returns a +// getter that reads the current bool value on each call. func GetBool(c *viper.Viper, key string, fallback bool) func() bool { setDefault(c, key, "bool", fallback) @@ -38,6 +43,8 @@ func GetBool(c *viper.Viper, key string, fallback bool) func() bool { } } +// MustGetBool registers key as required on c and returns a getter that reads +// the current bool value on each call. It panics if key is not set. func MustGetBool(c *viper.Viper, key string) func() bool { must(c, key, "bool") @@ -46,6 +53,8 @@ func MustGetBool(c *viper.Viper, key string) func() bool { } } +// GetInt registers key with fallback as its default on c and returns a +// getter that reads the current int value on each call. func GetInt(c *viper.Viper, key string, fallback int) func() int { setDefault(c, key, "int", fallback) @@ -54,6 +63,8 @@ func GetInt(c *viper.Viper, key string, fallback int) func() int { } } +// MustGetInt registers key as required on c and returns a getter that reads +// the current int value on each call. It panics if key is not set. func MustGetInt(c *viper.Viper, key string) func() int { must(c, key, "int") @@ -62,6 +73,8 @@ func MustGetInt(c *viper.Viper, key string) func() int { } } +// GetInt32 registers key with fallback as its default on c and returns a +// getter that reads the current int32 value on each call. func GetInt32(c *viper.Viper, key string, fallback int32) func() int32 { setDefault(c, key, "int32", fallback) @@ -70,6 +83,8 @@ func GetInt32(c *viper.Viper, key string, fallback int32) func() int32 { } } +// MustGetInt32 registers key as required on c and returns a getter that reads +// the current int32 value on each call. It panics if key is not set. func MustGetInt32(c *viper.Viper, key string) func() int32 { must(c, key, "int32") @@ -78,6 +93,8 @@ func MustGetInt32(c *viper.Viper, key string) func() int32 { } } +// GetInt64 registers key with fallback as its default on c and returns a +// getter that reads the current int64 value on each call. func GetInt64(c *viper.Viper, key string, fallback int64) func() int64 { setDefault(c, key, "int64", fallback) @@ -86,6 +103,8 @@ func GetInt64(c *viper.Viper, key string, fallback int64) func() int64 { } } +// MustGetInt64 registers key as required on c and returns a getter that reads +// the current int64 value on each call. It panics if key is not set. func MustGetInt64(c *viper.Viper, key string) func() int64 { must(c, key, "int64") @@ -94,6 +113,8 @@ func MustGetInt64(c *viper.Viper, key string) func() int64 { } } +// GetUint registers key with fallback as its default on c and returns a +// getter that reads the current uint value on each call. func GetUint(c *viper.Viper, key string, fallback uint) func() uint { setDefault(c, key, "uint", fallback) @@ -102,6 +123,8 @@ func GetUint(c *viper.Viper, key string, fallback uint) func() uint { } } +// MustGetUint registers key as required on c and returns a getter that reads +// the current uint value on each call. It panics if key is not set. func MustGetUint(c *viper.Viper, key string) func() uint { must(c, key, "uint") @@ -110,6 +133,8 @@ func MustGetUint(c *viper.Viper, key string) func() uint { } } +// GetUint32 registers key with fallback as its default on c and returns a +// getter that reads the current uint32 value on each call. func GetUint32(c *viper.Viper, key string, fallback uint32) func() uint32 { setDefault(c, key, "uint32", fallback) @@ -118,6 +143,8 @@ func GetUint32(c *viper.Viper, key string, fallback uint32) func() uint32 { } } +// MustGetUint32 registers key as required on c and returns a getter that reads +// the current uint32 value on each call. It panics if key is not set. func MustGetUint32(c *viper.Viper, key string) func() uint32 { must(c, key, "uint32") @@ -126,6 +153,8 @@ func MustGetUint32(c *viper.Viper, key string) func() uint32 { } } +// GetUint64 registers key with fallback as its default on c and returns a +// getter that reads the current uint64 value on each call. func GetUint64(c *viper.Viper, key string, fallback uint64) func() uint64 { setDefault(c, key, "uint64", fallback) @@ -134,6 +163,8 @@ func GetUint64(c *viper.Viper, key string, fallback uint64) func() uint64 { } } +// MustGetUint64 registers key as required on c and returns a getter that reads +// the current uint64 value on each call. It panics if key is not set. func MustGetUint64(c *viper.Viper, key string) func() uint64 { must(c, key, "uint64") @@ -142,6 +173,8 @@ func MustGetUint64(c *viper.Viper, key string) func() uint64 { } } +// GetFloat64 registers key with fallback as its default on c and returns a +// getter that reads the current float64 value on each call. func GetFloat64(c *viper.Viper, key string, fallback float64) func() float64 { setDefault(c, key, "float64", fallback) @@ -150,6 +183,8 @@ func GetFloat64(c *viper.Viper, key string, fallback float64) func() float64 { } } +// MustGetFloat64 registers key as required on c and returns a getter that reads +// the current float64 value on each call. It panics if key is not set. func MustGetFloat64(c *viper.Viper, key string) func() float64 { must(c, key, "float64") @@ -158,6 +193,8 @@ func MustGetFloat64(c *viper.Viper, key string) func() float64 { } } +// GetString registers key with fallback as its default on c and returns a +// getter that reads the current string value on each call. func GetString(c *viper.Viper, key, fallback string) func() string { setDefault(c, key, "string", fallback) @@ -166,6 +203,8 @@ func GetString(c *viper.Viper, key, fallback string) func() string { } } +// MustGetString registers key as required on c and returns a getter that reads +// the current string value on each call. It panics if key is not set. func MustGetString(c *viper.Viper, key string) func() string { must(c, key, "string") @@ -174,6 +213,8 @@ func MustGetString(c *viper.Viper, key string) func() string { } } +// GetTime registers key with fallback as its default on c and returns a +// getter that reads the current [time.Time] value on each call. func GetTime(c *viper.Viper, key string, fallback time.Time) func() time.Time { setDefault(c, key, "time.Time", fallback) @@ -182,6 +223,8 @@ func GetTime(c *viper.Viper, key string, fallback time.Time) func() time.Time { } } +// MustGetTime registers key as required on c and returns a getter that reads +// the current [time.Time] value on each call. It panics if key is not set. func MustGetTime(c *viper.Viper, key string) func() time.Time { must(c, key, "time.Time") @@ -190,6 +233,8 @@ func MustGetTime(c *viper.Viper, key string) func() time.Time { } } +// GetDuration registers key with fallback as its default on c and returns a +// getter that reads the current [time.Duration] value on each call. func GetDuration(c *viper.Viper, key string, fallback time.Duration) func() time.Duration { setDefault(c, key, "time.Duration", fallback) @@ -198,6 +243,8 @@ func GetDuration(c *viper.Viper, key string, fallback time.Duration) func() time } } +// MustGetDuration registers key as required on c and returns a getter that reads +// the current [time.Duration] value on each call. It panics if key is not set. func MustGetDuration(c *viper.Viper, key string) func() time.Duration { must(c, key, "time.Duration") @@ -206,6 +253,8 @@ func MustGetDuration(c *viper.Viper, key string) func() time.Duration { } } +// GetIntSlice registers key with fallback as its default on c and returns a +// getter that reads the current []int value on each call. func GetIntSlice(c *viper.Viper, key string, fallback []int) func() []int { setDefault(c, key, "[]int", fallback) @@ -214,6 +263,8 @@ func GetIntSlice(c *viper.Viper, key string, fallback []int) func() []int { } } +// MustGetIntSlice registers key as required on c and returns a getter that reads +// the current []int value on each call. It panics if key is not set. func MustGetIntSlice(c *viper.Viper, key string) func() []int { must(c, key, "[]int") @@ -222,6 +273,8 @@ func MustGetIntSlice(c *viper.Viper, key string) func() []int { } } +// GetStringSlice registers key with fallback as its default on c and returns a +// getter that reads the current []string value on each call. func GetStringSlice(c *viper.Viper, key string, fallback []string) func() []string { setDefault(c, key, "[]string", fallback) @@ -230,6 +283,8 @@ func GetStringSlice(c *viper.Viper, key string, fallback []string) func() []stri } } +// MustGetStringSlice registers key as required on c and returns a getter that reads +// the current []string value on each call. It panics if key is not set. func MustGetStringSlice(c *viper.Viper, key string) func() []string { must(c, key, "[]string") @@ -238,6 +293,8 @@ func MustGetStringSlice(c *viper.Viper, key string) func() []string { } } +// GetStringMap registers key with fallback as its default on c and returns a +// getter that reads the current map[string]any value on each call. func GetStringMap(c *viper.Viper, key string, fallback map[string]any) func() map[string]any { setDefault(c, key, "map[string]interface{}", fallback) @@ -246,6 +303,8 @@ func GetStringMap(c *viper.Viper, key string, fallback map[string]any) func() ma } } +// MustGetStringMap registers key as required on c and returns a getter that reads +// the current map[string]any value on each call. It panics if key is not set. func MustGetStringMap(c *viper.Viper, key string) func() map[string]any { must(c, key, "map[string]interface{}") @@ -254,6 +313,8 @@ func MustGetStringMap(c *viper.Viper, key string) func() map[string]any { } } +// GetStringMapString registers key with fallback as its default on c and returns a +// getter that reads the current map[string]string value on each call. func GetStringMapString(c *viper.Viper, key string, fallback map[string]string) func() map[string]string { setDefault(c, key, "map[string]string", fallback) @@ -262,6 +323,8 @@ func GetStringMapString(c *viper.Viper, key string, fallback map[string]string) } } +// MustGetStringMapString registers key as required on c and returns a getter that reads +// the current map[string]string value on each call. It panics if key is not set. func MustGetStringMapString(c *viper.Viper, key string) func() map[string]string { must(c, key, "map[string]string") @@ -270,6 +333,8 @@ func MustGetStringMapString(c *viper.Viper, key string) func() map[string]string } } +// GetStringMapStringSlice registers key with fallback as its default on c and returns a +// getter that reads the current map[string][]string value on each call. func GetStringMapStringSlice(c *viper.Viper, key string, fallback map[string][]string) func() map[string][]string { setDefault(c, key, "map[string][]string", fallback) @@ -278,6 +343,8 @@ func GetStringMapStringSlice(c *viper.Viper, key string, fallback map[string][]s } } +// MustGetStringMapStringSlice registers key as required on c and returns a getter that reads +// the current map[string][]string value on each call. It panics if key is not set. func MustGetStringMapStringSlice(c *viper.Viper, key string) func() map[string][]string { must(c, key, "map[string][]string") @@ -286,6 +353,10 @@ func MustGetStringMapStringSlice(c *viper.Viper, key string) func() map[string][ } } +// GetStruct decodes fallback (using "yaml" struct tags) into defaults below +// key, merges them into c and returns a function that decodes the current +// value at key into v. A nil c selects the global instance. It returns an +// error if fallback cannot be decoded or merged. func GetStruct(c *viper.Viper, key string, fallback any) (func(v any) error, error) { c = ensure(c) @@ -323,18 +394,22 @@ func GetStruct(c *viper.Viper, key string, fallback any) (func(v any) error, err }, nil } +// RequiredKeys returns all keys registered as required. func RequiredKeys() []string { return requiredKeys } +// Defaults returns the registered default values by key. func Defaults() map[string]any { return defaults } +// Types returns the registered type names by key. func Types() map[string]string { return types } +// TypeOf returns the registered type name of key, or "" if key is unknown. func TypeOf(key string) string { if v, ok := types[key]; ok { return v @@ -343,6 +418,7 @@ func TypeOf(key string) string { return "" } +// ensure returns c, or the global instance if c is nil. func ensure(c *viper.Viper) *viper.Viper { if c == nil { c = config @@ -351,6 +427,7 @@ func ensure(c *viper.Viper) *viper.Viper { return c } +// must records key as required with type typeof and panics if it is not set. func must(c *viper.Viper, key, typeof string) { c = ensure(c) types[key] = typeof @@ -361,6 +438,7 @@ func must(c *viper.Viper, key, typeof string) { } } +// decode converts input into output using mapstructure with "yaml" tags. func decode(input, output any) error { decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ TagName: "yaml", @@ -373,6 +451,7 @@ func decode(input, output any) error { return decoder.Decode(input) } +// setDefault sets fallback as the default for key and records its type. func setDefault(c *viper.Viper, key, typeof string, fallback any) { c = ensure(c) c.SetDefault(key, fallback) diff --git a/config/doc.go b/config/doc.go new file mode 100644 index 00000000..bcfb187a --- /dev/null +++ b/config/doc.go @@ -0,0 +1,27 @@ +// Package config provides typed, lazily evaluated accessors for +// configuration values backed by [github.com/spf13/viper]. +// +// A global [github.com/spf13/viper.Viper] instance, returned by [Config], reads environment +// variables automatically (with "." in keys replaced by "_") and treats +// config sources as YAML. Accessors take the *viper.Viper to read from; +// [Get], [MustGet] and [GetStruct] accept nil to select the global instance. +// +// # Getters +// +// Get* functions register a key with a default and return a getter that +// reads the current value on each call. MustGet* functions register a +// required key and panic if it is not set: +// +// addr := config.Get(nil, "service.addr", ":8080") +// token := config.MustGet[string](nil, "service.token") +// fmt.Println(addr(), token()) +// +// Registered keys, defaults and types can be inspected with +// [RequiredKeys], [Defaults], [Types] and [TypeOf]. +// +// # Watching +// +// [Watch] and [WatchChan] (and their typed variants) poll a getter every +// second and report changes, which is useful together with remote providers +// added through [WithRemoteConfig]. +package config diff --git a/config/generic.go b/config/generic.go index 59dbb60a..670233a3 100644 --- a/config/generic.go +++ b/config/generic.go @@ -16,7 +16,9 @@ type Supported interface { map[string]any | map[string]string | map[string][]string } -// Get registers a config key with a fallback default and returns a getter closure. +// Get registers key with fallback as its default on c and returns a getter +// that reads the current value on each call. A nil c selects the global +// instance. func Get[T Supported](c *viper.Viper, key string, fallback T) func() T { setDefault(c, key, fmt.Sprintf("%T", fallback), fallback) @@ -25,8 +27,9 @@ func Get[T Supported](c *viper.Viper, key string, fallback T) func() T { } } -// MustGet registers a required config key and returns a getter closure. -// Panics if the key is not set. +// MustGet registers key as required on c and returns a getter that reads the +// current value on each call. A nil c selects the global instance. It panics +// if key is not set. func MustGet[T Supported](c *viper.Viper, key string) func() T { var zero T must(c, key, fmt.Sprintf("%T", zero)) @@ -36,7 +39,9 @@ func MustGet[T Supported](c *viper.Viper, key string) func() T { } } -// Watch polls the getter and calls the callback when the value changes. +// Watch starts a goroutine that polls fn every second until ctx is done and +// calls callback from that goroutine whenever the value changes. It returns +// immediately. func Watch[T comparable](ctx context.Context, fn func() T, callback func(T)) { current := fn() @@ -48,7 +53,9 @@ func Watch[T comparable](ctx context.Context, fn func() T, callback func(T)) { }) } -// WatchChan polls the getter and sends on ch when the value changes. +// WatchChan starts a goroutine that polls fn every second until ctx is done +// and sends the value on ch whenever it changes. Sends block, so ch must be +// drained. It returns immediately. func WatchChan[T comparable](ctx context.Context, fn func() T, ch chan T) { current := fn() @@ -60,6 +67,8 @@ func WatchChan[T comparable](ctx context.Context, fn func() T, ch chan T) { }) } +// getTyped reads key from c using the viper getter matching T. +// //nolint:forcetypeassert func getTyped[T Supported](c *viper.Viper, key string) T { c = ensure(c) diff --git a/config/readme.go b/config/readme.go index 92e37ddf..c0682095 100644 --- a/config/readme.go +++ b/config/readme.go @@ -6,7 +6,10 @@ import ( "github.com/foomo/keel/markdown" ) -// Deprecated: will be removed in future releases +// Readme returns a markdown table of all registered config keys and remote +// providers. +// +// Deprecated: Readme will be removed in a future release. func Readme() string { var ( configRows [][]string diff --git a/config/remote.go b/config/remote.go index ae54652d..234806d7 100644 --- a/config/remote.go +++ b/config/remote.go @@ -6,12 +6,16 @@ import ( _ "github.com/spf13/viper/remote" // required import ) +// remotes records every remote provider added through [WithRemoteConfig]. var remotes []struct { provider string endpoint string path string } +// WithRemoteConfig adds a remote provider (e.g. "etcd3", "consul") at +// endpoint and path to c, reads it and starts watching it for changes. It +// returns an error if any of these steps fail. func WithRemoteConfig(c *viper.Viper, provider, endpoint string, path string) error { if err := c.AddRemoteProvider(provider, endpoint, path); err != nil { return err diff --git a/config/watch.go b/config/watch.go index cd308718..d833e471 100644 --- a/config/watch.go +++ b/config/watch.go @@ -5,8 +5,7 @@ import ( "time" ) -// Watch callback - +// WatchBool is [Watch] for bool values. func WatchBool(ctx context.Context, fn func() bool, callback func(bool)) { current := fn() @@ -18,6 +17,7 @@ func WatchBool(ctx context.Context, fn func() bool, callback func(bool)) { }) } +// WatchTime is [Watch] for time.Time values. func WatchTime(ctx context.Context, fn func() time.Time, callback func(time.Time)) { current := fn() @@ -29,6 +29,7 @@ func WatchTime(ctx context.Context, fn func() time.Time, callback func(time.Time }) } +// WatchDuration is [Watch] for time.Duration values. func WatchDuration(ctx context.Context, fn func() time.Duration, callback func(time.Duration)) { current := fn() @@ -40,6 +41,7 @@ func WatchDuration(ctx context.Context, fn func() time.Duration, callback func(t }) } +// WatchInt is [Watch] for int values. func WatchInt(ctx context.Context, fn func() int, callback func(int)) { current := fn() @@ -51,6 +53,7 @@ func WatchInt(ctx context.Context, fn func() int, callback func(int)) { }) } +// WatchInt32 is [Watch] for int32 values. func WatchInt32(ctx context.Context, fn func() int32, callback func(int32)) { current := fn() @@ -62,6 +65,7 @@ func WatchInt32(ctx context.Context, fn func() int32, callback func(int32)) { }) } +// WatchInt64 is [Watch] for int64 values. func WatchInt64(ctx context.Context, fn func() int64, callback func(int64)) { current := fn() @@ -73,6 +77,7 @@ func WatchInt64(ctx context.Context, fn func() int64, callback func(int64)) { }) } +// WatchFloat64 is [Watch] for float64 values. func WatchFloat64(ctx context.Context, fn func() float64, callback func(float64)) { current := fn() @@ -84,6 +89,7 @@ func WatchFloat64(ctx context.Context, fn func() float64, callback func(float64) }) } +// WatchString is [Watch] for string values. func WatchString(ctx context.Context, fn func() string, callback func(string)) { current := fn() @@ -95,8 +101,7 @@ func WatchString(ctx context.Context, fn func() string, callback func(string)) { }) } -// Watch channel - +// WatchBoolChan is [WatchChan] for bool values. func WatchBoolChan(ctx context.Context, fn func() bool, ch chan bool) { current := fn() @@ -108,6 +113,7 @@ func WatchBoolChan(ctx context.Context, fn func() bool, ch chan bool) { }) } +// WatchTimeChan is [WatchChan] for time.Time values. func WatchTimeChan(ctx context.Context, fn func() time.Time, ch chan time.Time) { current := fn() @@ -119,6 +125,7 @@ func WatchTimeChan(ctx context.Context, fn func() time.Time, ch chan time.Time) }) } +// WatchDurationChan is [WatchChan] for time.Duration values. func WatchDurationChan(ctx context.Context, fn func() time.Duration, ch chan time.Duration) { current := fn() @@ -130,6 +137,7 @@ func WatchDurationChan(ctx context.Context, fn func() time.Duration, ch chan tim }) } +// WatchIntChan is [WatchChan] for int values. func WatchIntChan(ctx context.Context, fn func() int, ch chan int) { current := fn() @@ -141,6 +149,7 @@ func WatchIntChan(ctx context.Context, fn func() int, ch chan int) { }) } +// WatchInt32Chan is [WatchChan] for int32 values. func WatchInt32Chan(ctx context.Context, fn func() int32, ch chan int32) { current := fn() @@ -152,6 +161,7 @@ func WatchInt32Chan(ctx context.Context, fn func() int32, ch chan int32) { }) } +// WatchInt64Chan is [WatchChan] for int64 values. func WatchInt64Chan(ctx context.Context, fn func() int64, ch chan int64) { current := fn() @@ -163,6 +173,7 @@ func WatchInt64Chan(ctx context.Context, fn func() int64, ch chan int64) { }) } +// WatchFloat64Chan is [WatchChan] for float64 values. func WatchFloat64Chan(ctx context.Context, fn func() float64, ch chan float64) { current := fn() @@ -174,6 +185,7 @@ func WatchFloat64Chan(ctx context.Context, fn func() float64, ch chan float64) { }) } +// WatchStringChan is [WatchChan] for string values. func WatchStringChan(ctx context.Context, fn func() string, ch chan string) { current := fn() @@ -185,6 +197,7 @@ func WatchStringChan(ctx context.Context, fn func() string, ch chan string) { }) } +// watch calls fn every second in a new goroutine until ctx is done. func watch(ctx context.Context, fn func()) { go func(ctx context.Context, fn func()) { for { diff --git a/doc.go b/doc.go new file mode 100644 index 00000000..26f90dac --- /dev/null +++ b/doc.go @@ -0,0 +1,63 @@ +// Package keel provides an opinionated runtime for services and jobs running +// on Kubernetes, with graceful shutdown, structured logging, configuration, +// OpenTelemetry and health probes. +// +// # Server +// +// A [Server] orchestrates a set of [Service] values. It is created with +// [NewServer] and configured through [Option] values such as +// [WithHTTPZapService], [WithHTTPHealthzService] or [WithTelemetry]. Logging +// defaults to [log.Logger] and configuration to [config.Config]. +// +// svr := keel.NewServer( +// keel.WithHTTPZapService(true), +// keel.WithHTTPViperService(true), +// keel.WithHTTPPrometheusService(true), +// keel.WithHTTPHealthzService(true), +// ) +// +// svr.AddService(service.NewHTTP(svr.Logger(), "demo", "localhost:8080", handler)) +// +// svr.Run() +// +// # Services +// +// Init services (registered by the WithHTTPXxxService options or +// [WithInitService]) are started immediately inside [NewServer]. Services +// added with [Server.AddService], [Server.AddHTTPService], +// [Server.AddGoRoutine] and friends are started by [Server.Run]. Ready made +// implementations live in [github.com/foomo/keel/service]. [ServiceFunc] +// adapts a plain function and [ServiceEnabler] starts and stops a service +// dynamically. +// +// # Graceful shutdown +// +// [Server.Run] blocks until a shutdown signal (SIGINT and SIGTERM by default, +// see [WithShutdownSignals]) is received, [Server.ShutdownCancel] is called, +// or a service fails. The server then reports not ready, and calls every +// registered closer followed by the trace, meter and logger providers, all +// bounded by the graceful period (KEEL_GRACEFUL_PERIOD seconds, default 30, +// see [WithGracefulPeriod]). A closer is any value implementing one of the +// Close, Shutdown, Stop or Unsubscribe shapes declared in +// [github.com/foomo/keel/interfaces]; see [IsCloser]. Services are +// registered as closers automatically. +// +// # Health probes +// +// Probes are registered per [healthz.Type] with [Server.AddStartupHealthzers], +// [Server.AddReadinessHealthzers], [Server.AddLivenessHealthzers] and +// [Server.AddAlwaysHealthzers]; see [IsHealthz] for the accepted shapes. They +// are served by the service enabled through [WithHTTPHealthzService]. +// +// # Jobs +// +// A [Job], created with [NewJob] and configured through [JobOption] values, +// runs a list of steps to completion instead of blocking until a signal, as +// suited for a Kubernetes Job. Both [Server] and [Job] implement [Runtime]. +// +// job := keel.NewJob(keel.JobWithTelemetry()) +// job.AddStep("migrate", func(ctx context.Context, l *zap.Logger) error { +// return migrate(ctx) +// }) +// job.Run() +package keel diff --git a/env/doc.go b/env/doc.go new file mode 100644 index 00000000..e5f0b998 --- /dev/null +++ b/env/doc.go @@ -0,0 +1,12 @@ +// Package env provides typed accessors for environment variables with +// fallback defaults. +// +// Get* functions return the parsed value or a fallback; MustGet* functions +// panic when the variable is not set. Every accessed key is recorded with its +// type and default, which can be inspected through [RequiredKeys], +// [Defaults], [Types] and [TypeOf]. All functions are safe for concurrent +// use. +// +// port := env.GetInt("PORT", 8080) +// token := env.MustGet("API_TOKEN") +package env diff --git a/env/env.go b/env/env.go index e3a2a330..628b0ad8 100644 --- a/env/env.go +++ b/env/env.go @@ -8,19 +8,23 @@ import ( "sync" ) +// Registries of accessed keys, recorded on first access and safe for +// concurrent use: the type name and default per key, and the set of required +// keys. var ( types = sync.Map{} defaults = sync.Map{} requiredKeys = sync.Map{} ) -// Exists return true if env var is defined +// Exists reports whether the environment variable key is set, even if empty. func Exists(key string) bool { _, ok := os.LookupEnv(key) return ok } -// MustExists panics if not exists +// MustExists panics if the environment variable key is not set and +// otherwise records key as required. func MustExists(key string) { if !Exists(key) { panic(fmt.Sprintf("required environment variable `%s` does not exist", key)) @@ -31,7 +35,8 @@ func MustExists(key string) { } } -// Get env var or fallback +// Get returns the value of the environment variable key, or fallback if it +// is not set. The first fallback seen for key is recorded as its default. func Get(key, fallback string) string { if _, ok := defaults.Load(key); !ok { defaults.Store(key, fallback) @@ -48,13 +53,15 @@ func Get(key, fallback string) string { return fallback } -// MustGet env var or panic +// MustGet returns the value of the environment variable key and panics if +// it is not set. func MustGet(key string) string { MustExists(key) return Get(key, "") } -// GetInt env var or fallback as int +// GetInt returns the environment variable key parsed as int, or fallback if +// it is not set or cannot be parsed. func GetInt(key string, fallback int) int { if _, ok := types.Load(key); !ok { types.Store(key, "int") @@ -67,13 +74,15 @@ func GetInt(key string, fallback int) int { return fallback } -// MustGetInt env var as int or panic +// MustGetInt returns the environment variable key parsed as int and panics +// if it is not set. An unparsable value yields 0. func MustGetInt(key string) int { MustExists(key) return GetInt(key, 0) } -// GetInt64 env var or fallback as int64 +// GetInt64 returns the environment variable key parsed as base-10 int64, or +// fallback if it is not set or cannot be parsed. func GetInt64(key string, fallback int64) int64 { if _, ok := types.Load(key); !ok { types.Store(key, "int64") @@ -86,13 +95,15 @@ func GetInt64(key string, fallback int64) int64 { return fallback } -// MustGetInt64 env var as int64 or panic +// MustGetInt64 returns the environment variable key parsed as int64 and +// panics if it is not set. An unparsable value yields 0. func MustGetInt64(key string) int64 { MustExists(key) return GetInt64(key, 0) } -// GetFloat64 env var or fallback as float64 +// GetFloat64 returns the environment variable key parsed as float64, or +// fallback if it is not set or cannot be parsed. func GetFloat64(key string, fallback float64) float64 { if _, ok := types.Load(key); !ok { types.Store(key, "float64") @@ -105,13 +116,15 @@ func GetFloat64(key string, fallback float64) float64 { return fallback } -// MustGetFloat64 env var as float64 or panic +// MustGetFloat64 returns the environment variable key parsed as float64 and +// panics if it is not set. An unparsable value yields 0. func MustGetFloat64(key string) float64 { MustExists(key) return GetFloat64(key, 0) } -// GetBool env var or fallback as bool +// GetBool returns the environment variable key parsed with +// [strconv.ParseBool], or fallback if it is not set or cannot be parsed. func GetBool(key string, fallback bool) bool { if _, ok := types.Load(key); !ok { types.Store(key, "bool") @@ -124,13 +137,15 @@ func GetBool(key string, fallback bool) bool { return fallback } -// MustGetBool env var as bool or panic +// MustGetBool returns the environment variable key parsed as bool and panics +// if it is not set. An unparsable value yields false. func MustGetBool(key string) bool { MustExists(key) return GetBool(key, false) } -// GetStringSlice env var or fallback as []string +// GetStringSlice returns the environment variable key split on ",", or +// fallback if it is unset or empty. Elements are not trimmed. func GetStringSlice(key string, fallback []string) []string { if _, ok := types.Load(key); !ok { types.Store(key, "[]string") @@ -143,13 +158,15 @@ func GetStringSlice(key string, fallback []string) []string { return fallback } -// MustGetStringSlice env var as bool or panic +// MustGetStringSlice returns the environment variable key split on "," and +// panics if it is not set. func MustGetStringSlice(key string) []string { MustExists(key) return GetStringSlice(key, nil) } -// GetIntSlice env var or fallback as []string +// GetIntSlice returns the environment variable key split on "," and parsed +// as ints, or fallback if it is unset, empty or any element cannot be parsed. func GetIntSlice(key string, fallback []int) []int { if _, ok := types.Load(key); !ok { types.Store(key, "[]int") @@ -172,12 +189,14 @@ func GetIntSlice(key string, fallback []int) []int { return fallback } -// MustGetGetIntSlice env var as bool or panic +// MustGetGetIntSlice returns the environment variable key as []int (see +// [GetIntSlice]) and panics if it is not set. func MustGetGetIntSlice(key string) []int { MustExists(key) return GetIntSlice(key, nil) } +// RequiredKeys returns all keys recorded as required, in unspecified order. func RequiredKeys() []string { var ret []string @@ -192,6 +211,7 @@ func RequiredKeys() []string { return ret } +// Defaults returns the recorded default values by key. func Defaults() map[string]any { ret := map[string]any{} @@ -206,6 +226,7 @@ func Defaults() map[string]any { return ret } +// Types returns the recorded type names by key. func Types() map[string]string { ret := map[string]string{} @@ -222,6 +243,7 @@ func Types() map[string]string { return ret } +// TypeOf returns the recorded type name of key, or "" if key is unknown. func TypeOf(key string) string { if v, ok := types.Load(key); ok { if s, ok := v.(string); ok { diff --git a/env/readme.go b/env/readme.go index 28889cef..78e1c3af 100644 --- a/env/readme.go +++ b/env/readme.go @@ -6,7 +6,9 @@ import ( "github.com/foomo/keel/markdown" ) -// Deprecated: will be removed in future releases +// Readme returns a markdown table of all accessed environment variables. +// +// Deprecated: Readme will be removed in a future release. func Readme() string { var rows [][]string diff --git a/errors.go b/errors.go index 60f162a0..a4304296 100644 --- a/errors.go +++ b/errors.go @@ -5,6 +5,9 @@ import ( ) var ( + // ErrServerNotRunning is returned by [Server.Healthz] while the server is not running. ErrServerNotRunning = errors.New("server not running") - ErrServerShutdown = errors.New("server is shutting down") + // ErrServerShutdown is returned by the readiness probe and the server's error + // group once graceful shutdown has started. + ErrServerShutdown = errors.New("server is shutting down") ) diff --git a/examples/config/doc.go b/examples/config/doc.go new file mode 100644 index 00000000..098f0001 --- /dev/null +++ b/examples/config/doc.go @@ -0,0 +1,6 @@ +// Config is an example service demonstrating typed configuration getters, +// struct configuration and environment variable overrides via the config package. +// +// Run it with `go run ./examples/config`, then try `curl localhost:8081` to see the +// resolved values and `curl localhost:9300/config` to inspect the viper configuration. +package main diff --git a/examples/config/main.go b/examples/config/main.go index 31eb64a1..0cdc4c9c 100644 --- a/examples/config/main.go +++ b/examples/config/main.go @@ -15,6 +15,7 @@ import ( ) type ( + // Nested is the nested part of Config. Nested struct { Int int `yaml:"int"` Bool bool `yaml:"bool"` @@ -22,6 +23,7 @@ type ( CamelCaseString string `yaml:"camelCaseString"` SnakeCaseString string `yaml:"snake_case_string"` //nolint:tagliatelle } + // Config is the struct loaded from the struct configuration key. Config struct { Int int `yaml:"int"` Bool bool `yaml:"bool"` diff --git a/examples/graceful/doc.go b/examples/graceful/doc.go new file mode 100644 index 00000000..27e8adf1 --- /dev/null +++ b/examples/graceful/doc.go @@ -0,0 +1,6 @@ +// Graceful is an example service demonstrating graceful shutdown with readiness +// probes and registered closers. +// +// Run it with `go run ./examples/graceful`. It polls localhost:8080 and the +// readiness probe on localhost:9400, then sends itself SIGTERM and logs the shutdown sequence. +package main diff --git a/examples/healthz/doc.go b/examples/healthz/doc.go new file mode 100644 index 00000000..cc62ce7a --- /dev/null +++ b/examples/healthz/doc.go @@ -0,0 +1,6 @@ +// Healthz is an example service demonstrating startup, readiness, liveness and +// always health probes. +// +// Run it with `go run ./examples/healthz`, then try `curl localhost:9400/healthz/readiness` +// (or /healthz, /healthz/liveness, /healthz/startup). +package main diff --git a/examples/healthz/handler/doc.go b/examples/healthz/handler/doc.go new file mode 100644 index 00000000..a503736e --- /dev/null +++ b/examples/healthz/handler/doc.go @@ -0,0 +1,2 @@ +// Package handler provides a logging health probe used by the healthz example. +package handler diff --git a/examples/healthz/handler/handler.go b/examples/healthz/handler/handler.go index 242efff7..30b28bbc 100644 --- a/examples/healthz/handler/handler.go +++ b/examples/healthz/handler/handler.go @@ -4,15 +4,18 @@ import ( "go.uber.org/zap" ) +// Handler is a health probe that logs its name and always reports healthy. type Handler struct { l *zap.Logger name string } +// New returns a Handler logging with l under the given name. func New(l *zap.Logger, name string) *Handler { return &Handler{l: l, name: name} } +// Healthz logs the handler name and returns true. func (h *Handler) Healthz() bool { h.l.Info(h.name) return true diff --git a/examples/job/doc.go b/examples/job/doc.go new file mode 100644 index 00000000..c60a54ee --- /dev/null +++ b/examples/job/doc.go @@ -0,0 +1,5 @@ +// Job is an example demonstrating a run-to-completion job with sequential steps, +// a timeout and closers. +// +// Run it with `go run ./examples/job`; the process exits with 0 on success and 1 on failure. +package main diff --git a/examples/logging/doc.go b/examples/logging/doc.go new file mode 100644 index 00000000..8c1a62a4 --- /dev/null +++ b/examples/logging/doc.go @@ -0,0 +1,5 @@ +// Logging is an example service demonstrating the log package helpers for errors, +// durations and HTTP requests, together with the zap log level service. +// +// Run it with `go run ./examples/logging`, then try `curl localhost:8080`. +package main diff --git a/examples/logging/main.go b/examples/logging/main.go index 86e4a78a..da6f5b60 100644 --- a/examples/logging/main.go +++ b/examples/logging/main.go @@ -10,10 +10,12 @@ import ( "github.com/foomo/keel/service" ) +// CustomError is an error type wrapping another error. type CustomError struct { error } +// Example errors logged by the demo handler. var ( ErrCustom = &CustomError{error: errors.New("custom error")} ErrStandard = errors.New("string error") diff --git a/examples/middlewares/basicauth/doc.go b/examples/middlewares/basicauth/doc.go new file mode 100644 index 00000000..500aab3a --- /dev/null +++ b/examples/middlewares/basicauth/doc.go @@ -0,0 +1,5 @@ +// Basicauth is an example service demonstrating the basic auth middleware. +// +// Run it with `go run ./examples/middlewares/basicauth`, then try +// `curl -u demo:demo localhost:8080`. +package main diff --git a/examples/middlewares/cors/doc.go b/examples/middlewares/cors/doc.go new file mode 100644 index 00000000..670538d6 --- /dev/null +++ b/examples/middlewares/cors/doc.go @@ -0,0 +1,6 @@ +// Cors is an example service demonstrating the CORS middleware with allowed +// origins and methods. +// +// Run it with `go run ./examples/middlewares/cors`, then try +// `curl -i -H 'Origin: example.com' localhost:8080`. +package main diff --git a/examples/middlewares/jwtfromcookie/doc.go b/examples/middlewares/jwtfromcookie/doc.go new file mode 100644 index 00000000..c604a36d --- /dev/null +++ b/examples/middlewares/jwtfromcookie/doc.go @@ -0,0 +1,6 @@ +// Jwtfromcookie is an example service demonstrating the JWT middleware reading the +// token from a cookie, creating it when missing and updating its claims per path. +// +// Run it with `go run ./examples/middlewares/jwtfromcookie`, then open +// localhost:8080, localhost:8080/fr or localhost:8080/en in a browser. +package main diff --git a/examples/middlewares/jwtfromcookie/main.go b/examples/middlewares/jwtfromcookie/main.go index c1c7f826..c8b34e96 100644 --- a/examples/middlewares/jwtfromcookie/main.go +++ b/examples/middlewares/jwtfromcookie/main.go @@ -18,12 +18,14 @@ import ( httputils "github.com/foomo/keel/utils/net/http" ) +// CustomClaims are the JWT claims stored in the cookie. type CustomClaims struct { gojwt.RegisteredClaims Name string `json:"name"` Language string `json:"language"` } +// ContextKey is the request context key holding the parsed CustomClaims. const ( ContextKey = "custom" ) diff --git a/examples/middlewares/jwtfromtoken/doc.go b/examples/middlewares/jwtfromtoken/doc.go new file mode 100644 index 00000000..2c727fa9 --- /dev/null +++ b/examples/middlewares/jwtfromtoken/doc.go @@ -0,0 +1,7 @@ +// Jwtfromtoken is an example service demonstrating the JWT middleware reading a +// required token from a custom header. +// +// Run it with `go run ./examples/middlewares/jwtfromtoken`, fetch a token with +// `curl localhost:8080/token`, then try +// `curl -H 'X-Authorization: Custom ' localhost:8080`. +package main diff --git a/examples/middlewares/logger/doc.go b/examples/middlewares/logger/doc.go new file mode 100644 index 00000000..b6a45f29 --- /dev/null +++ b/examples/middlewares/logger/doc.go @@ -0,0 +1,4 @@ +// Logger is an example service demonstrating the request logger middleware. +// +// Run it with `go run ./examples/middlewares/logger`, then try `curl localhost:8080`. +package main diff --git a/examples/middlewares/recover/doc.go b/examples/middlewares/recover/doc.go new file mode 100644 index 00000000..0d88fc15 --- /dev/null +++ b/examples/middlewares/recover/doc.go @@ -0,0 +1,5 @@ +// Recover is an example service demonstrating the recover middleware handling panics. +// +// Run it with `go run ./examples/middlewares/recover`, then try `curl localhost:8080` +// or `curl localhost:8080/abort`. +package main diff --git a/examples/middlewares/requestid/doc.go b/examples/middlewares/requestid/doc.go new file mode 100644 index 00000000..6aead7df --- /dev/null +++ b/examples/middlewares/requestid/doc.go @@ -0,0 +1,5 @@ +// Requestid is an example service demonstrating the request ID middleware with a +// custom generator and response header. +// +// Run it with `go run ./examples/middlewares/requestid`, then try `curl -i localhost:8080`. +package main diff --git a/examples/middlewares/responsetime/doc.go b/examples/middlewares/responsetime/doc.go new file mode 100644 index 00000000..4f8d67d6 --- /dev/null +++ b/examples/middlewares/responsetime/doc.go @@ -0,0 +1,6 @@ +// Responsetime is an example service demonstrating the response time middleware +// with a maximum duration. +// +// Run it with `go run ./examples/middlewares/responsetime`, then try +// `curl -i localhost:8080` or `curl -i localhost:8080/sleep`. +package main diff --git a/examples/middlewares/sessionid/doc.go b/examples/middlewares/sessionid/doc.go new file mode 100644 index 00000000..039d778a --- /dev/null +++ b/examples/middlewares/sessionid/doc.go @@ -0,0 +1,5 @@ +// Sessionid is an example service demonstrating the session ID middleware with a +// custom cookie, time provider and domain provider. +// +// Run it with `go run ./examples/middlewares/sessionid`, then try `curl -i localhost:8080`. +package main diff --git a/examples/middlewares/skip/doc.go b/examples/middlewares/skip/doc.go new file mode 100644 index 00000000..80c2d102 --- /dev/null +++ b/examples/middlewares/skip/doc.go @@ -0,0 +1,6 @@ +// Skip is an example service demonstrating the skip middleware with request URI +// blacklist and whitelist skippers. +// +// Run it with `go run ./examples/middlewares/skip`, then compare +// `curl localhost:8080/skip` with `curl localhost:8081/skip`. +package main diff --git a/examples/middlewares/telemetry/doc.go b/examples/middlewares/telemetry/doc.go new file mode 100644 index 00000000..126aebaf --- /dev/null +++ b/examples/middlewares/telemetry/doc.go @@ -0,0 +1,5 @@ +// Telemetry is an example service demonstrating the telemetry middleware with a +// stdout tracer and propagation header injection. +// +// Run it with `go run ./examples/middlewares/telemetry`, then try `curl -i localhost:8080`. +package main diff --git a/examples/middlewares/tokenauthfromcookie/doc.go b/examples/middlewares/tokenauthfromcookie/doc.go new file mode 100644 index 00000000..d2317375 --- /dev/null +++ b/examples/middlewares/tokenauthfromcookie/doc.go @@ -0,0 +1,6 @@ +// Tokenauthfromcookie is an example service demonstrating the token auth middleware +// reading the token from a cookie. +// +// Run it with `go run ./examples/middlewares/tokenauthfromcookie`, then try +// `curl -b keel-token=some-random-token localhost:8080`. +package main diff --git a/examples/middlewares/tokenauthfromheader/doc.go b/examples/middlewares/tokenauthfromheader/doc.go new file mode 100644 index 00000000..3219cbe3 --- /dev/null +++ b/examples/middlewares/tokenauthfromheader/doc.go @@ -0,0 +1,6 @@ +// Tokenauthfromheader is an example service demonstrating the token auth middleware +// reading the token from a custom header. +// +// Run it with `go run ./examples/middlewares/tokenauthfromheader`, then try +// `curl -H 'X-Authorization: Custom some-random-token' localhost:8080`. +package main diff --git a/examples/persistence/mongo/doc.go b/examples/persistence/mongo/doc.go new file mode 100644 index 00000000..e482a293 --- /dev/null +++ b/examples/persistence/mongo/doc.go @@ -0,0 +1,6 @@ +// Mongo is an example service demonstrating the MongoDB persistor with indexes, +// a custom codec and versioned entities raising duplicate key and dirty write errors. +// +// Start MongoDB with `docker run -it --rm -p 27017:27017 mongo`, then run +// `go run ./examples/persistence/mongo`. +package main diff --git a/examples/persistence/mongo/repository/doc.go b/examples/persistence/mongo/repository/doc.go new file mode 100644 index 00000000..f76db70a --- /dev/null +++ b/examples/persistence/mongo/repository/doc.go @@ -0,0 +1,2 @@ +// Package repository provides the MongoDB repository used by the mongo persistence example. +package repository diff --git a/examples/persistence/mongo/repository/dummy.go b/examples/persistence/mongo/repository/dummy.go index 58a33a37..bff6fa9f 100644 --- a/examples/persistence/mongo/repository/dummy.go +++ b/examples/persistence/mongo/repository/dummy.go @@ -9,17 +9,19 @@ import ( keelmongo "github.com/foomo/keel/persistence/mongo" ) +// DummyRepository stores store.Dummy entities in a MongoDB collection. type DummyRepository struct { collection *keelmongo.Collection } -// NewDummyRepository constructor +// NewDummyRepository returns a DummyRepository backed by collection. func NewDummyRepository(collection *keelmongo.Collection) *DummyRepository { return &DummyRepository{ collection: collection, } } +// Get returns the entity with the given id. func (r *DummyRepository) Get(ctx context.Context, id string, opts ...options.Lister[options.FindOneOptions]) (*store.Dummy, error) { var ret store.Dummy if err := r.collection.Get(ctx, id, &ret, opts...); err != nil { @@ -29,6 +31,7 @@ func (r *DummyRepository) Get(ctx context.Context, id string, opts ...options.Li return &ret, nil } +// Insert inserts entity. func (r *DummyRepository) Insert(ctx context.Context, entity *store.Dummy) error { if err := r.collection.Insert(ctx, entity); err != nil { return err @@ -37,6 +40,7 @@ func (r *DummyRepository) Insert(ctx context.Context, entity *store.Dummy) error return nil } +// Upsert inserts or updates entity by its ID. func (r *DummyRepository) Upsert(ctx context.Context, entity *store.Dummy) error { if err := r.collection.Upsert(ctx, entity.GetID(), entity); err != nil { return err @@ -45,6 +49,7 @@ func (r *DummyRepository) Upsert(ctx context.Context, entity *store.Dummy) error return nil } +// UpsertMany inserts or updates all entities. func (r *DummyRepository) UpsertMany(ctx context.Context, entities []*store.Dummy) error { v := make([]keelmongo.Entity, len(entities)) for i, entity := range entities { @@ -58,6 +63,7 @@ func (r *DummyRepository) UpsertMany(ctx context.Context, entities []*store.Dumm return nil } +// Delete deletes the entity with the given id. func (r *DummyRepository) Delete(ctx context.Context, id string) error { return r.collection.Delete(ctx, id) } diff --git a/examples/persistence/mongo/store/codec.go b/examples/persistence/mongo/store/codec.go index d5eee35b..cff6e77a 100644 --- a/examples/persistence/mongo/store/codec.go +++ b/examples/persistence/mongo/store/codec.go @@ -9,12 +9,16 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) +// TDateTime is the reflect type of DateTime. var ( TDateTime = reflect.TypeFor[DateTime]() ) +// DateTimeCodec encodes DateTime values as BSON datetimes and decodes them from +// BSON datetimes or strings. type DateTimeCodec struct{} +// EncodeValue writes val as a BSON datetime. func (d *DateTimeCodec) EncodeValue(_ bson.EncodeContext, vw bson.ValueWriter, val reflect.Value) error { if !val.IsValid() || val.Type() != TDateTime { return bson.ValueEncoderError{Name: "DateTimeEncodeValue", Types: []reflect.Type{TDateTime}, Received: val} @@ -33,6 +37,7 @@ func (d *DateTimeCodec) EncodeValue(_ bson.EncodeContext, vw bson.ValueWriter, v return vw.WriteDateTime(tt.UnixMilli()) } +// DecodeValue reads a BSON datetime or string into val. func (d *DateTimeCodec) DecodeValue(_ bson.DecodeContext, vr bson.ValueReader, val reflect.Value) error { if !val.CanSet() || val.Type() != TDateTime { return bson.ValueDecoderError{Name: "DecimalDecodeValue", Types: []reflect.Type{TDateTime}, Received: val} diff --git a/examples/persistence/mongo/store/datetime.go b/examples/persistence/mongo/store/datetime.go index 81aaf82d..def90683 100644 --- a/examples/persistence/mongo/store/datetime.go +++ b/examples/persistence/mongo/store/datetime.go @@ -4,23 +4,23 @@ import ( "time" ) -// DateTimeLayout in the ISO8601 format with millisecond precision +// DateTimeLayout is the ISO 8601 layout with millisecond precision. const DateTimeLayout = "2006-01-02T15:04:05.000Z0700" -// DateTime type +// DateTime is a time formatted with DateTimeLayout. type DateTime string -// NewDateTime constructor +// NewDateTime returns t formatted as DateTime. func NewDateTime(t time.Time) DateTime { return DateTime(t.Format(DateTimeLayout)) } -// Time returns the date time as Time +// Time parses the date time into a time.Time. func (d DateTime) Time() (time.Time, error) { return time.Parse(DateTimeLayout, string(d)) } -// MustTime returns the date time as Time and panics on failure +// MustTime is like Time but panics on failure. func (d DateTime) MustTime() time.Time { t, err := d.Time() if err != nil { @@ -30,7 +30,7 @@ func (d DateTime) MustTime() time.Time { return t } -// String returns the string representation +// String returns the string representation. func (d DateTime) String() string { return string(d) } diff --git a/examples/persistence/mongo/store/doc.go b/examples/persistence/mongo/store/doc.go new file mode 100644 index 00000000..6512f1e5 --- /dev/null +++ b/examples/persistence/mongo/store/doc.go @@ -0,0 +1,3 @@ +// Package store provides the entities, indexes and BSON codec used by the mongo +// persistence example. +package store diff --git a/examples/persistence/mongo/store/dummy.go b/examples/persistence/mongo/store/dummy.go index c78e0b79..2677efa1 100644 --- a/examples/persistence/mongo/store/dummy.go +++ b/examples/persistence/mongo/store/dummy.go @@ -1,5 +1,6 @@ package store +// Dummy is an example entity with an ID, a version and timestamps. type Dummy struct { Entity `json:",inline" bson:",inline"` EntityWithVersions `json:",inline" bson:",inline"` diff --git a/examples/persistence/mongo/store/entity.go b/examples/persistence/mongo/store/entity.go index 82138c48..57c85efd 100644 --- a/examples/persistence/mongo/store/entity.go +++ b/examples/persistence/mongo/store/entity.go @@ -6,6 +6,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// EntityIndex is a unique index on the id field. var ( EntityIndex = mongo.IndexModel{ Keys: bson.D{ @@ -15,22 +16,23 @@ var ( } ) -// Entity type +// Entity holds the ID and the MongoDB object ID of a document. type Entity struct { ID string `json:"id" bson:"id" yaml:"id"` BsonID bson.ObjectID `json:"_id,omitempty" bson:"_id,omitempty" yaml:"_id,omitempty"` //nolint:tagliatelle } +// NewEntity returns an Entity with the given id. func NewEntity(id string) Entity { return Entity{ID: id} } -// GetID api implementation +// GetID returns the ID. func (e *Entity) GetID() string { return e.ID } -// SetID api implementation +// SetID sets the ID. func (e *Entity) SetID(value string) { e.ID = value } diff --git a/examples/persistence/mongo/store/entitywithtimestamps.go b/examples/persistence/mongo/store/entitywithtimestamps.go index 6357f112..e4450604 100644 --- a/examples/persistence/mongo/store/entitywithtimestamps.go +++ b/examples/persistence/mongo/store/entitywithtimestamps.go @@ -4,27 +4,28 @@ import ( "time" ) +// EntityWithTimestamps holds the creation and update times of a document. type EntityWithTimestamps struct { CreatedAt time.Time `json:"createdAt" bson:"createdAt" yaml:"createdAt"` UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt" yaml:"updatedAt"` } -// GetCreatedAt api implementation +// GetCreatedAt returns the creation time. func (e *EntityWithTimestamps) GetCreatedAt() time.Time { return e.CreatedAt } -// SetCreatedAt api implementation +// SetCreatedAt sets the creation time. func (e *EntityWithTimestamps) SetCreatedAt(t time.Time) { e.CreatedAt = t } -// GetUpdatedAt api implementation +// GetUpdatedAt returns the update time. func (e *EntityWithTimestamps) GetUpdatedAt() time.Time { return e.UpdatedAt } -// SetUpdatedAt api implementation +// SetUpdatedAt sets the update time. func (e *EntityWithTimestamps) SetUpdatedAt(t time.Time) { e.UpdatedAt = t } diff --git a/examples/persistence/mongo/store/entitywithversions.go b/examples/persistence/mongo/store/entitywithversions.go index 7b08fdab..e100c2d1 100644 --- a/examples/persistence/mongo/store/entitywithversions.go +++ b/examples/persistence/mongo/store/entitywithversions.go @@ -6,6 +6,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// EntityWithVersionsIndex is a unique index on the id and version fields. var ( EntityWithVersionsIndex = mongo.IndexModel{ Keys: bson.D{ @@ -16,22 +17,22 @@ var ( } ) -// EntityWithVersions type +// EntityWithVersions holds the version of a document used to detect dirty writes. type EntityWithVersions struct { Version uint32 `json:"version" bson:"version" yaml:"version"` } -// GetVersion api implementation +// GetVersion returns the version. func (e *EntityWithVersions) GetVersion() uint32 { return e.Version } -// SetVersion api implementation +// SetVersion sets the version. func (e *EntityWithVersions) SetVersion(value uint32) { e.Version = value } -// IncreaseVersion api implementation +// IncreaseVersion increments the version and returns it. func (e *EntityWithVersions) IncreaseVersion() uint32 { e.Version++ return e.GetVersion() diff --git a/examples/persistence/postgres/doc.go b/examples/persistence/postgres/doc.go new file mode 100644 index 00000000..687232ae --- /dev/null +++ b/examples/persistence/postgres/doc.go @@ -0,0 +1,8 @@ +// Postgres is an example service demonstrating the PostgreSQL persistor with an +// init statement and a simple repository. +// +// Start PostgreSQL and run the example: +// +// docker run -it --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres postgres:11-alpine +// go run ./examples/persistence/postgres +package main diff --git a/examples/persistence/postgres/repository/doc.go b/examples/persistence/postgres/repository/doc.go new file mode 100644 index 00000000..cc192a91 --- /dev/null +++ b/examples/persistence/postgres/repository/doc.go @@ -0,0 +1,2 @@ +// Package repository provides the task repository used by the postgres persistence example. +package repository diff --git a/examples/persistence/postgres/repository/dummy.go b/examples/persistence/postgres/repository/dummy.go index b27219e9..0b740c7f 100644 --- a/examples/persistence/postgres/repository/dummy.go +++ b/examples/persistence/postgres/repository/dummy.go @@ -6,17 +6,19 @@ import ( keelpostgres "github.com/foomo/keel/persistence/postgres" ) +// TaskRepository stores task descriptions in the tasks table. type TaskRepository struct { persistor *keelpostgres.Persistor } -// NewTaskRepository constructor +// NewTaskRepository returns a TaskRepository backed by persistor. func NewTaskRepository(persistor *keelpostgres.Persistor) *TaskRepository { return &TaskRepository{ persistor: persistor, } } +// List returns all task descriptions keyed by ID. func (r *TaskRepository) List(ctx context.Context) (map[int32]string, error) { conn, err := r.persistor.Conn(ctx) if err != nil { @@ -49,6 +51,7 @@ func (r *TaskRepository) List(ctx context.Context) (map[int32]string, error) { return ret, rows.Err() } +// Insert adds a task with the given description. func (r *TaskRepository) Insert(ctx context.Context, description string) error { conn, err := r.persistor.Conn(ctx) if err != nil { @@ -61,6 +64,7 @@ func (r *TaskRepository) Insert(ctx context.Context, description string) error { return err } +// Drop drops the order_numbers table if it exists. func (r *TaskRepository) Drop(ctx context.Context) error { conn, err := r.persistor.Conn(ctx) if err != nil { diff --git a/examples/remoteconfig/doc.go b/examples/remoteconfig/doc.go new file mode 100644 index 00000000..ba2ca14b --- /dev/null +++ b/examples/remoteconfig/doc.go @@ -0,0 +1,6 @@ +// Remoteconfig is an example service demonstrating remote configuration from etcd +// and watching value changes. +// +// Start etcd on localhost:2379, run `go run ./examples/remoteconfig` and change foo +// in the cluster.yaml key; `curl localhost:8080` prints the current value to stdout. +package main diff --git a/examples/roundtripwares/logger/doc.go b/examples/roundtripwares/logger/doc.go new file mode 100644 index 00000000..1deeca73 --- /dev/null +++ b/examples/roundtripwares/logger/doc.go @@ -0,0 +1,5 @@ +// Logger is an example demonstrating the logger roundtripware on an HTTP client. +// +// Run it with `go run ./examples/roundtripwares/logger`; it starts a server on +// localhost:8080, sends two requests and exits after five seconds. +package main diff --git a/examples/roundtripwares/requestid/doc.go b/examples/roundtripwares/requestid/doc.go new file mode 100644 index 00000000..141f82f9 --- /dev/null +++ b/examples/roundtripwares/requestid/doc.go @@ -0,0 +1,5 @@ +// Requestid is an example service demonstrating the request, session and tracking +// ID roundtripwares propagating IDs to an internal request. +// +// Run it with `go run ./examples/roundtripwares/requestid`, then try `curl localhost:8080`. +package main diff --git a/examples/roundtripwares/retry/doc.go b/examples/roundtripwares/retry/doc.go new file mode 100644 index 00000000..fec99ba6 --- /dev/null +++ b/examples/roundtripwares/retry/doc.go @@ -0,0 +1,5 @@ +// Retry is an example demonstrating the retry roundtripware against a server that +// fails the first requests with 503. +// +// Run it with `go run ./examples/roundtripwares/retry`; it exits after 60 seconds. +package main diff --git a/examples/serviceenabler/doc.go b/examples/serviceenabler/doc.go new file mode 100644 index 00000000..ee52cafd --- /dev/null +++ b/examples/serviceenabler/doc.go @@ -0,0 +1,6 @@ +// Serviceenabler is an example service demonstrating a service that is started and +// stopped at runtime through a config value. +// +// Run it with `go run ./examples/serviceenabler`; each `curl localhost:8080` toggles +// the service on localhost:8081. +package main diff --git a/examples/services/doc.go b/examples/services/doc.go new file mode 100644 index 00000000..1d1ee9aa --- /dev/null +++ b/examples/services/doc.go @@ -0,0 +1,6 @@ +// Services is an example demonstrating the built-in zap, viper, prometheus and +// pprof services. +// +// Run it with `go run ./examples/services`, then try `curl localhost:9100/log`, +// `curl localhost:9300/config`, `curl localhost:9200/metrics` or `curl localhost:6060/debug/pprof/`. +package main diff --git a/examples/stream/jetstream/doc.go b/examples/stream/jetstream/doc.go new file mode 100644 index 00000000..02af6dd4 --- /dev/null +++ b/examples/stream/jetstream/doc.go @@ -0,0 +1,6 @@ +// Jetstream is an example service demonstrating publishing and subscribing to typed +// messages with the NATS JetStream stream. +// +// Start NATS with `docker run -it -p 4222:4222 --rm nats:2.7-alpine --jetstream`, run +// `go run ./examples/stream/jetstream`, then publish with `curl localhost:8080`. +package main diff --git a/examples/stream/jetstreamraw/doc.go b/examples/stream/jetstreamraw/doc.go new file mode 100644 index 00000000..ead38878 --- /dev/null +++ b/examples/stream/jetstreamraw/doc.go @@ -0,0 +1,6 @@ +// Jetstreamraw is an example service demonstrating raw publishing and subscribing +// through the underlying NATS JetStream context. +// +// Start NATS with `docker run -it -p 4222:4222 --rm nats:2.7-alpine --jetstream`, run +// `go run ./examples/stream/jetstreamraw`, then publish with `curl localhost:8080`. +package main diff --git a/examples/telemetry/doc.go b/examples/telemetry/doc.go new file mode 100644 index 00000000..9cc83fa5 --- /dev/null +++ b/examples/telemetry/doc.go @@ -0,0 +1,6 @@ +// Telemetry is an example service demonstrating OpenTelemetry counters, up down +// counters and histograms alongside Prometheus exemplars, configured via OTEL_* env vars. +// +// Run it with e.g. `OTEL_METRICS_EXPORTER=console go run ./examples/telemetry`, then try +// `curl localhost:8080/count`, /up, /down or /histogram. +package main diff --git a/healthz.go b/healthz.go index 7b437763..b06de814 100644 --- a/healthz.go +++ b/healthz.go @@ -5,6 +5,10 @@ import ( "github.com/foomo/keel/interfaces" ) +// IsHealthz reports whether v implements one of the accepted probe interfaces: +// [healthz.BoolHealthzer], [healthz.BoolHealthzerWithContext], +// [healthz.ErrorHealthzer], [healthz.ErrorHealthzWithContext], +// [interfaces.ErrorPinger] or [interfaces.ErrorPingerWithContext]. func IsHealthz(v any) bool { switch v.(type) { case healthz.BoolHealthzer, diff --git a/healthz/doc.go b/healthz/doc.go new file mode 100644 index 00000000..7f221fbd --- /dev/null +++ b/healthz/doc.go @@ -0,0 +1,10 @@ +// Package healthz provides the probe types and interfaces used by keel to +// implement Kubernetes startup, readiness and liveness checks. +// +// A probe is any value implementing one of [BoolHealthzer], +// [BoolHealthzerWithContext], [ErrorHealthzer] or [ErrorHealthzWithContext] +// (keel also accepts the pinger interfaces from +// [github.com/foomo/keel/interfaces]). Probes are registered on a +// [github.com/foomo/keel.Server] per [Type] and are served by the health +// service from [github.com/foomo/keel/service.NewHealthz]. +package healthz diff --git a/healthz/docs.go b/healthz/docs.go deleted file mode 100644 index 59c42b6c..00000000 --- a/healthz/docs.go +++ /dev/null @@ -1 +0,0 @@ -package healthz diff --git a/healthz/healthzer.go b/healthz/healthzer.go index 5ed08cfd..995b5709 100644 --- a/healthz/healthzer.go +++ b/healthz/healthzer.go @@ -2,40 +2,47 @@ package healthz import "context" +// healther adapts a function to the [ErrorHealthzWithContext] interface. type healther struct { handle func(context.Context) error } +// NewHealthzerFn returns a probe that calls handle on each health check. +// The returned value also implements Close(ctx) error by calling handle. func NewHealthzerFn(handle func(context.Context) error) healther { return healther{ handle: handle, } } +// Healthz calls the wrapped function. func (h healther) Healthz(ctx context.Context) error { return h.handle(ctx) } +// Close calls the wrapped function. func (h healther) Close(ctx context.Context) error { return h.handle(ctx) } -// BoolHealthzer interface +// BoolHealthzer is a probe that reports whether it is healthy. type BoolHealthzer interface { Healthz() bool } -// BoolHealthzerWithContext interface +// BoolHealthzerWithContext is a probe that reports whether it is healthy, +// honoring the request context. type BoolHealthzerWithContext interface { Healthz(ctx context.Context) bool } -// ErrorHealthzer interface +// ErrorHealthzer is a probe that returns a non-nil error when unhealthy. type ErrorHealthzer interface { Healthz() error } -// ErrorHealthzWithContext interface +// ErrorHealthzWithContext is a probe that returns a non-nil error when +// unhealthy, honoring the request context. type ErrorHealthzWithContext interface { Healthz(ctx context.Context) error } diff --git a/healthz/type.go b/healthz/type.go index e5c45ba2..f7af087b 100644 --- a/healthz/type.go +++ b/healthz/type.go @@ -1,31 +1,31 @@ package healthz -// Type type -// https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +// Type is the kind of health check a probe is registered for. See +// https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/. type Type string const ( - // TypeAlways will run on any checks + // TypeAlways probes run on every check. TypeAlways Type = "always" - // TypeStartup will run on /healthz/startup checks + // TypeStartup probes run on /healthz/startup checks. // > The kubelet uses startup probes to know when a container application has started. If such a probe is configured, // > it disables liveness and readiness checks until it succeeds, making sure those probes don't interfere with the // > application startup. This can be used to adopt liveness checks on slow starting containers, avoiding them getting // > killed by the kubelet before they are up and running. TypeStartup Type = "startup" - // TypeReadiness will run on /healthz/readiness checks + // TypeReadiness probes run on /healthz/readiness checks. // > The kubelet uses readiness probes to know when a container is ready to start accepting traffic. // > A Pod is considered ready when all of its containers are ready. One use of this signal is to control // > which Pods are used as backends for Services. When a Pod is not ready, it is removed from Service load balancers. TypeReadiness Type = "readiness" - // TypeLiveness will run on /healthz/liveness checks + // TypeLiveness probes run on /healthz/liveness checks. // > The kubelet uses liveness probes to know when to restart a container. For example, liveness probes could catch // > a deadlock, where an application is running, but unable to make progress. Restarting a container in such a state // > can help to make the application more available despite bugs. TypeLiveness Type = "liveness" ) -// String interface +// String returns t as a string. func (t Type) String() string { return string(t) } diff --git a/integration/gotsrpc/doc.go b/integration/gotsrpc/doc.go new file mode 100644 index 00000000..85dea326 --- /dev/null +++ b/integration/gotsrpc/doc.go @@ -0,0 +1,8 @@ +// Package gotsrpc provides keel service wrappers for gotsrpc GoRPC clients +// and proxies. +// +// [GoRPCClientService] and [GoRPCProxyService] adapt a [GoRPCClient] or +// [GoRPCProxy] to the keel service lifecycle: the wrapped value is started +// when the service starts and stopped once the service is closed or its +// context is canceled. +package gotsrpc diff --git a/integration/gotsrpc/gorpcclient.go b/integration/gotsrpc/gorpcclient.go index 831f971b..5a42b4c6 100644 --- a/integration/gotsrpc/gorpcclient.go +++ b/integration/gotsrpc/gorpcclient.go @@ -1,5 +1,6 @@ package gotsrpc +// GoRPCClient is a gotsrpc GoRPC client that can be started and stopped. type GoRPCClient interface { Start() Stop() diff --git a/integration/gotsrpc/gorpcclientservice.go b/integration/gotsrpc/gorpcclientservice.go index 8b2577f8..b013fd6d 100644 --- a/integration/gotsrpc/gorpcclientservice.go +++ b/integration/gotsrpc/gorpcclientservice.go @@ -4,17 +4,21 @@ import ( "context" ) +// GoRPCClientService runs a [GoRPCClient] for the lifetime of a service. type GoRPCClientService struct { client GoRPCClient cancel context.CancelFunc } +// NewGoRPCClientService returns a service wrapping client. func NewGoRPCClientService(client GoRPCClient) *GoRPCClientService { return &GoRPCClientService{ client: client, } } +// Start starts the client and blocks until ctx is canceled or [GoRPCClientService.Close] +// is called, then stops the client and returns the context error. func (s *GoRPCClientService) Start(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) s.cancel = cancel @@ -25,6 +29,7 @@ func (s *GoRPCClientService) Start(ctx context.Context) error { return ctx.Err() } +// Close unblocks a running [GoRPCClientService.Start]. It always returns nil. func (s *GoRPCClientService) Close(ctx context.Context) error { if s.cancel != nil { s.cancel() diff --git a/integration/gotsrpc/gorpcproxy.go b/integration/gotsrpc/gorpcproxy.go index 157ecd75..1bc891cb 100644 --- a/integration/gotsrpc/gorpcproxy.go +++ b/integration/gotsrpc/gorpcproxy.go @@ -1,5 +1,6 @@ package gotsrpc +// GoRPCProxy is a gotsrpc GoRPC proxy that can be started and stopped. type GoRPCProxy interface { Start() error Stop() diff --git a/integration/gotsrpc/gorpcproxyservice.go b/integration/gotsrpc/gorpcproxyservice.go index 972c2dac..66148d04 100644 --- a/integration/gotsrpc/gorpcproxyservice.go +++ b/integration/gotsrpc/gorpcproxyservice.go @@ -4,17 +4,22 @@ import ( "context" ) +// GoRPCProxyService runs a [GoRPCProxy] for the lifetime of a service. type GoRPCProxyService struct { proxy GoRPCProxy cancel context.CancelFunc } +// NewGoRPCProxyService returns a service wrapping proxy. func NewGoRPCProxyService(proxy GoRPCProxy) *GoRPCProxyService { return &GoRPCProxyService{ proxy: proxy, } } +// Start starts the proxy and blocks until ctx is canceled or [GoRPCProxyService.Close] +// is called, then stops the proxy and returns the context error. If the proxy +// fails to start, its error is returned immediately. func (s *GoRPCProxyService) Start(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) @@ -29,6 +34,7 @@ func (s *GoRPCProxyService) Start(ctx context.Context) error { return ctx.Err() } +// Close unblocks a running [GoRPCProxyService.Start]. It always returns nil. func (s *GoRPCProxyService) Close(ctx context.Context) error { if s.cancel != nil { s.cancel() diff --git a/integration/gotsrpc/net/middleware/doc.go b/integration/gotsrpc/net/middleware/doc.go new file mode 100644 index 00000000..70041896 --- /dev/null +++ b/integration/gotsrpc/net/middleware/doc.go @@ -0,0 +1,9 @@ +// Package keelgotsrpcmiddleware provides HTTP middleware that adds gotsrpc +// call telemetry to keel HTTP services. +// +// [Telemetry] renames the active span after the called gotsrpc service and +// function, annotates it and the request logger with gotsrpc attributes and +// records the execution duration metric defined in +// [github.com/foomo/keel/semconv/gotsrpcconv]. The middleware is deprecated +// in favor of gotsrpc v3, which includes OpenTelemetry instrumentation. +package keelgotsrpcmiddleware diff --git a/integration/gotsrpc/net/middleware/telemetry.go b/integration/gotsrpc/net/middleware/telemetry.go index e95bc08d..429af600 100644 --- a/integration/gotsrpc/net/middleware/telemetry.go +++ b/integration/gotsrpc/net/middleware/telemetry.go @@ -24,15 +24,21 @@ import ( ) type ( + // TelemetryOptions configures the [Telemetry] middleware. TelemetryOptions struct { - meter metric.Meter - bucketBoundaries []float64 + meter metric.Meter + bucketBoundaries []float64 + // PayloadAttributeDisabled reports whether the request payload is + // omitted from the span attributes. PayloadAttributeDisabled bool } + // TelemetryOption modifies [TelemetryOptions]. TelemetryOption func(*TelemetryOptions) ) -// DefaultTelemetryOptions returns the default options +// DefaultTelemetryOptions returns the default options: the keel meter, +// histogram buckets from 5ms to 10s and the payload attribute disabled unless +// OTEL_GOTSRPC_PAYLOAD_ATTRIBUTE_DISABLED is false. func DefaultTelemetryOptions() TelemetryOptions { return TelemetryOptions{ meter: telemetry.Meter(), @@ -41,47 +47,58 @@ func DefaultTelemetryOptions() TelemetryOptions { } } -// Deprecated: TelemetryWithExemplarsDisabled middleware option +// TelemetryWithExemplarsDisabled has no effect. +// +// Deprecated: Exemplars are not configurable. func TelemetryWithExemplarsDisabled(v bool) TelemetryOption { return func(o *TelemetryOptions) { } } -// Deprecated: TelemetryWithObserveExecution middleware option +// TelemetryWithObserveExecution has no effect. +// +// Deprecated: Execution is always observed. func TelemetryWithObserveExecution(v bool) TelemetryOption { return func(o *TelemetryOptions) { } } -// Deprecated: TelemetryWithObserveMarshalling middleware option +// TelemetryWithObserveMarshalling has no effect. +// +// Deprecated: Marshalling is not observed as a separate metric. func TelemetryWithObserveMarshalling(v bool) TelemetryOption { return func(o *TelemetryOptions) { } } -// Deprecated: TelemetryWithObserveUnmarshalling middleware option +// TelemetryWithObserveUnmarshalling has no effect. +// +// Deprecated: Unmarshalling is not observed as a separate metric. func TelemetryWithObserveUnmarshalling(v bool) TelemetryOption { return func(o *TelemetryOptions) { } } -// TelemetryWithBucketBoundries middleware option +// TelemetryWithBucketBoundries sets the explicit bucket boundaries, in +// seconds, of the execution duration histogram. func TelemetryWithBucketBoundries(v []float64) TelemetryOption { return func(o *TelemetryOptions) { o.bucketBoundaries = v } } -// TelemetryWithPayloadAttributeDisabled middleware option +// TelemetryWithPayloadAttributeDisabled sets whether the request payload is +// omitted from the span attributes. Defaults to true. func TelemetryWithPayloadAttributeDisabled(v bool) TelemetryOption { return func(o *TelemetryOptions) { o.PayloadAttributeDisabled = v } } -// Telemetry middleware +// Telemetry returns a middleware configured by [DefaultTelemetryOptions] and +// opts; see [TelemetryWithOptions]. Nil options are skipped. // -// Deprecated: use gotsrpc v3 as includes otel +// Deprecated: Use gotsrpc v3, which includes OpenTelemetry instrumentation. func Telemetry(opts ...TelemetryOption) keelhttp.Middleware { options := DefaultTelemetryOptions() @@ -94,9 +111,12 @@ func Telemetry(opts ...TelemetryOption) keelhttp.Middleware { return TelemetryWithOptions(options) } -// TelemetryWithOptions middleware +// TelemetryWithOptions returns a middleware that, after a gotsrpc call has +// been handled, renames the active span to "/", adds gotsrpc +// attributes and error details to the span and the request log labeler, and +// records the execution duration. // -// Deprecated: use gotsrpc v3 as includes otel +// Deprecated: Use gotsrpc v3, which includes OpenTelemetry instrumentation. func TelemetryWithOptions(opts TelemetryOptions) keelhttp.Middleware { m, err := gotsrpcconv.NewExecutionDuration( opts.meter, diff --git a/integration/nats/connect.go b/integration/nats/connect.go index e0cb59ec..5b3dc052 100644 --- a/integration/nats/connect.go +++ b/integration/nats/connect.go @@ -16,8 +16,14 @@ import ( "go.uber.org/zap" ) -// Connect establishes a connection to a NATS server with OTel instrumentation -// and structured logging on every lifecycle event. +// Connect establishes a connection to the NATS server at rawURL with OTel +// instrumentation and structured logging on every lifecycle event. +// +// The connection reconnects indefinitely every 2s (with jitter) and pings +// every 20s. These options and the lifecycle handlers are appended after opts +// and therefore take precedence over them. Disconnects, reconnects and async +// errors are recorded with the meter of s, and the connection is registered +// as a closer on s. func Connect(s keel.Runtime, rawURL string, opts ...nats.Option) (*nats.Conn, error) { l := s.Logger().Named("nats") m := s.Meter() diff --git a/integration/nats/doc.go b/integration/nats/doc.go new file mode 100644 index 00000000..526eac4d --- /dev/null +++ b/integration/nats/doc.go @@ -0,0 +1,17 @@ +// Package nats provides instrumented NATS and JetStream connection helpers +// for keel runtimes. +// +// [Connect] dials a NATS server with reconnect defaults, structured logging +// for connection lifecycle events and OpenTelemetry metrics for disconnects, +// reconnects and asynchronous errors. The connection is registered as a +// closer on the [github.com/foomo/keel.Runtime]. [NewJetStream] creates a +// JetStream context on top of such a connection with async publish defaults. +// +// # Usage +// +// nc, err := nats.Connect(svr, "nats://localhost:4222") +// if err != nil { +// return err +// } +// js, err := nats.NewJetStream(svr, nc) +package nats diff --git a/integration/nats/jetstream.go b/integration/nats/jetstream.go index c6fef9f2..2e92a0c6 100644 --- a/integration/nats/jetstream.go +++ b/integration/nats/jetstream.go @@ -11,8 +11,13 @@ import ( "go.uber.org/zap" ) -// NewJetStream creates a JetStream context with production-sensible async -// publish defaults and an instrumented async-error handler. +// NewJetStream creates a JetStream context on nc with production-sensible +// async publish defaults and an instrumented async-error handler. +// +// Defaults are a 5s default timeout, at most 256 pending async publishes and +// a 10s async publish timeout; they are applied before opts, so opts may +// override them. Async publish errors are logged and recorded with the meter +// of s. func NewJetStream(s keel.Runtime, nc *nats.Conn, opts ...jetstream.JetStreamOpt) (jetstream.JetStream, error) { l := s.Logger().Named("jetstream") m := s.Meter() diff --git a/integration/nats/service/doc.go b/integration/nats/service/doc.go new file mode 100644 index 00000000..3a07cdc2 --- /dev/null +++ b/integration/nats/service/doc.go @@ -0,0 +1,7 @@ +// Package service provides an embedded NATS server that runs as a keel +// service. +// +// [EmbeddedServer] starts an in-process NATS server in its Start method and +// blocks until the server shuts down, which makes it suitable for local +// development and tests. +package service diff --git a/integration/nats/service/embeddedserver.go b/integration/nats/service/embeddedserver.go index 00ae9fdb..035d9c2d 100644 --- a/integration/nats/service/embeddedserver.go +++ b/integration/nats/service/embeddedserver.go @@ -12,8 +12,12 @@ import ( "github.com/nats-io/nats-server/v2/server" ) +// DefaultEmbeddedServerURL is the client URL of an [EmbeddedServer] with the +// default host and port. const DefaultEmbeddedServerURL = "nats://0.0.0.0:4222" +// EmbeddedServer is an in-process NATS server usable as a keel service. +// Create it with [NewEmbeddedServer]. type EmbeddedServer struct { server *server.Server port int @@ -27,24 +31,30 @@ type EmbeddedServer struct { // ~ Options // ------------------------------------------------------------------------------------------------ +// EmbeddedServerWithPort sets the listen port. Defaults to 4222. func EmbeddedServerWithPort(v int) options.Option[*EmbeddedServer] { return func(o *EmbeddedServer) { o.port = v } } +// EmbeddedServerWithMaxPending sets the maximum number of bytes buffered per +// client connection. Defaults to 64 MiB. func EmbeddedServerWithMaxPending(v int64) options.Option[*EmbeddedServer] { return func(o *EmbeddedServer) { o.maxPending = v } } +// EmbeddedServerWithHost sets the listen host. Defaults to "0.0.0.0". func EmbeddedServerWithHost(v string) options.Option[*EmbeddedServer] { return func(o *EmbeddedServer) { o.host = v } } +// EmbeddedServerWithNatsOptions adds options applied to the NATS server +// options after host, port and max pending have been set. func EmbeddedServerWithNatsOptions(v ...options.Option[*server.Options]) options.Option[*EmbeddedServer] { return func(o *EmbeddedServer) { o.natsOptions = append(o.natsOptions, v...) @@ -55,6 +65,8 @@ func EmbeddedServerWithNatsOptions(v ...options.Option[*server.Options]) options // ~ Constructor // ------------------------------------------------------------------------------------------------ +// NewEmbeddedServer creates, but does not start, an embedded NATS server +// configured by opts. Server logging and signal handling are disabled. func NewEmbeddedServer(opts ...options.Option[*EmbeddedServer]) (*EmbeddedServer, error) { inst := &EmbeddedServer{ port: 4222, @@ -87,6 +99,7 @@ func NewEmbeddedServer(opts ...options.Option[*EmbeddedServer]) (*EmbeddedServer return &EmbeddedServer{server: ns, clientURL: u.String()}, nil } +// MustNewEmbeddedServer is like [NewEmbeddedServer] but panics on error. func MustNewEmbeddedServer(opts ...options.Option[*EmbeddedServer]) *EmbeddedServer { s, err := NewEmbeddedServer(opts...) if err != nil { @@ -105,6 +118,7 @@ func (s *EmbeddedServer) ClientURL() string { return s.clientURL } +// Server returns the underlying NATS server. func (s *EmbeddedServer) Server() *server.Server { return s.server } @@ -113,6 +127,8 @@ func (s *EmbeddedServer) Server() *server.Server { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Start starts the server and blocks until it shuts down. It returns an error +// if the server is not ready for connections within 5 seconds. func (s *EmbeddedServer) Start(ctx context.Context) error { s.server.Start() @@ -126,6 +142,8 @@ func (s *EmbeddedServer) Start(ctx context.Context) error { return nil } +// Close shuts the server down and waits for the shutdown to complete. It +// returns an error wrapping ctx.Err() if ctx is done first. func (s *EmbeddedServer) Close(ctx context.Context) error { if s.server == nil { return nil diff --git a/integration/temporal/activityoptions.go b/integration/temporal/activityoptions.go index 15960c4d..af7d893a 100644 --- a/integration/temporal/activityoptions.go +++ b/integration/temporal/activityoptions.go @@ -8,57 +8,73 @@ import ( ) type ( + // ActivityOption modifies [workflow.ActivityOptions]; see [WithActivityOptions]. ActivityOption func(options *workflow.ActivityOptions) ) +// ActivityOptionsWithTaskQueue sets the task queue the activity is scheduled on. func ActivityOptionsWithTaskQueue(v string) ActivityOption { return func(o *workflow.ActivityOptions) { o.TaskQueue = v } } +// ActivityOptionsWithScheduleToCloseTimeout sets the total time allowed for +// the activity, including retries. func ActivityOptionsWithScheduleToCloseTimeout(v time.Duration) ActivityOption { return func(o *workflow.ActivityOptions) { o.ScheduleToCloseTimeout = v } } +// ActivityOptionsWithScheduleToStartTimeout sets the time an activity task may +// wait in the task queue before being picked up by a worker. func ActivityOptionsWithScheduleToStartTimeout(v time.Duration) ActivityOption { return func(o *workflow.ActivityOptions) { o.ScheduleToStartTimeout = v } } +// ActivityOptionsWithStartToCloseTimeout sets the maximum duration of a single +// activity attempt. func ActivityOptionsWithStartToCloseTimeout(v time.Duration) ActivityOption { return func(o *workflow.ActivityOptions) { o.StartToCloseTimeout = v } } +// ActivityOptionsWithHeartbeatTimeout sets the maximum time allowed between +// activity heartbeats. func ActivityOptionsWithHeartbeatTimeout(v time.Duration) ActivityOption { return func(o *workflow.ActivityOptions) { o.HeartbeatTimeout = v } } +// ActivityOptionsWithWaitForCancellation sets whether the workflow waits for +// the activity to complete its cancellation before proceeding. func ActivityOptionsWithWaitForCancellation(v bool) ActivityOption { return func(o *workflow.ActivityOptions) { o.WaitForCancellation = v } } +// ActivityOptionsWithActivityID sets the business level activity ID. func ActivityOptionsWithActivityID(v string) ActivityOption { return func(o *workflow.ActivityOptions) { o.ActivityID = v } } +// ActivityOptionsWithRetryPolicy sets the retry policy of the activity. func ActivityOptionsWithRetryPolicy(v *temporal.RetryPolicy) ActivityOption { return func(o *workflow.ActivityOptions) { o.RetryPolicy = v } } +// WithActivityOptions returns a copy of ctx whose activity options are the +// options already present in ctx modified by opts. func WithActivityOptions(ctx workflow.Context, opts ...ActivityOption) workflow.Context { o := workflow.GetActivityOptions(ctx) for _, opt := range opts { diff --git a/integration/temporal/client.go b/integration/temporal/client.go index 71e1b71a..bb100a4f 100644 --- a/integration/temporal/client.go +++ b/integration/temporal/client.go @@ -20,40 +20,59 @@ import ( ) type ( + // ClientOptions configures [NewClient]. ClientOptions struct { - Logger *zap.Logger - Namespace string + // Logger receives the Temporal client logs. + Logger *zap.Logger + // Namespace is the namespace the client connects to. + Namespace string + // RegisterNamespace, when set, is registered if missing and updated + // otherwise, and its namespace replaces Namespace. RegisterNamespace *workflowservice.RegisterNamespaceRequest - OtelEnabled bool + // OtelEnabled reports whether tracing and metrics are enabled. + OtelEnabled bool + // ConnectionOptions are passed to the Temporal client unchanged. ConnectionOptions client.ConnectionOptions } + // ClientOption modifies [ClientOptions]. ClientOption func(o *ClientOptions) ) +// ClientWithOtelEnabled sets whether the client is instrumented with +// OpenTelemetry tracing and metrics. func ClientWithOtelEnabled(v bool) ClientOption { return func(o *ClientOptions) { o.OtelEnabled = v } } +// ClientWithNamespace sets the namespace the client connects to. Defaults to +// "default". func ClientWithNamespace(v string) ClientOption { return func(o *ClientOptions) { o.Namespace = v } } +// ClientWithRegisterNamespace sets a namespace that [NewClient] registers or +// updates before connecting to it. func ClientWithRegisterNamespace(v *workflowservice.RegisterNamespaceRequest) ClientOption { return func(o *ClientOptions) { o.RegisterNamespace = v } } +// ClientWithConnectionOptions sets the gRPC connection options of the client. func ClientWithConnectionOptions(v client.ConnectionOptions) ClientOption { return func(o *ClientOptions) { o.ConnectionOptions = v } } +// DefaultClientOptions returns the default [ClientOptions]: the keel logger, +// the "default" namespace and OpenTelemetry enabled according to the +// OTEL_TEMPORAL_ENABLED environment variable, falling back to OTEL_ENABLED +// and then false. func DefaultClientOptions() ClientOptions { return ClientOptions{ Logger: log.Logger(), @@ -64,6 +83,14 @@ func DefaultClientOptions() ClientOptions { } } +// NewClient dials the Temporal server at endpoint (host:port) and returns a +// client configured by [DefaultClientOptions] and opts. +// +// If a namespace to register is set, it is registered when it does not exist +// and otherwise updated with the requested settings; an error is returned if +// the namespace is not in the registered state. If OpenTelemetry is enabled, +// a tracing interceptor and a metrics handler using the keel telemetry +// providers are installed. func NewClient(ctx context.Context, endpoint string, opts ...ClientOption) (client.Client, error) { o := DefaultClientOptions() diff --git a/integration/temporal/doc.go b/integration/temporal/doc.go new file mode 100644 index 00000000..c5b8aa38 --- /dev/null +++ b/integration/temporal/doc.go @@ -0,0 +1,22 @@ +// Package keeltemporal provides helpers for running Temporal clients and +// workers inside a keel server. +// +// It covers client construction with optional namespace registration and +// OpenTelemetry instrumentation ([NewClient]), a keel service wrapper for +// Temporal workers ([NewService]), adapters bridging Temporal's logger and +// metrics interfaces to zap and OpenTelemetry ([NewLogger], +// [NewMetricsHandler]), activity option helpers ([WithActivityOptions]) and +// error classification helpers ([NewActivityError], [IsActivityError], +// [AsApplicationError]). +// +// # Usage +// +// c, err := keeltemporal.NewClient(ctx, "localhost:7233", +// keeltemporal.ClientWithNamespace("my-namespace"), +// ) +// if err != nil { +// return err +// } +// w := worker.New(c, "my-queue", worker.Options{}) +// svr.AddService(keeltemporal.NewService(nil, "temporal", w)) +package keeltemporal diff --git a/integration/temporal/error.go b/integration/temporal/error.go index 9b9a6f2e..1d02a6d4 100644 --- a/integration/temporal/error.go +++ b/integration/temporal/error.go @@ -7,12 +7,17 @@ import ( "go.temporal.io/sdk/temporal" ) +// ActivityErrorType is the application error type used by [NewActivityError]. const ActivityErrorType = "keeltemporal.ActivityError" +// NewActivityError returns a non-retryable [temporal.ApplicationError] of +// type [ActivityErrorType] wrapping err with msg and optional details. func NewActivityError(msg string, err error, details ...any) error { return temporal.NewNonRetryableApplicationError(msg, ActivityErrorType, err, details...) } +// IsErrorType reports whether err wraps a [temporal.ApplicationError] of the +// given errorType. func IsErrorType(err error, errorType string) bool { if applicationErr := AsApplicationError(err); applicationErr != nil && applicationErr.Type() == errorType { return true @@ -21,14 +26,20 @@ func IsErrorType(err error, errorType string) bool { return false } +// IsActivityError reports whether err wraps an error created by +// [NewActivityError]. func IsActivityError(err error) bool { return IsErrorType(err, ActivityErrorType) } +// IsApplicationError reports whether err wraps a [temporal.ApplicationError]. +// The handler argument is ignored. func IsApplicationError(err error, handler func(applicationErr *temporal.ApplicationError)) bool { return AsApplicationError(err) != nil } +// AsApplicationError returns the first [temporal.ApplicationError] in err's +// chain, or nil if there is none. func AsApplicationError(err error) *temporal.ApplicationError { var applicationErr *temporal.ApplicationError if err != nil && errors.As(err, &applicationErr) { @@ -38,10 +49,14 @@ func AsApplicationError(err error) *temporal.ApplicationError { return nil } +// IsCanceledError reports whether err wraps a [temporal.CanceledError]. +// The handler argument is ignored. func IsCanceledError(err error, handler func(canceledErr *temporal.CanceledError)) bool { return AsCanceledError(err) != nil } +// AsCanceledError returns the first [temporal.CanceledError] in err's chain, +// or nil if there is none. func AsCanceledError(err error) *temporal.CanceledError { var canceledErr *temporal.CanceledError if err != nil && errors.As(err, &canceledErr) { @@ -51,10 +66,14 @@ func AsCanceledError(err error) *temporal.CanceledError { return nil } +// IsTimeoutError reports whether err wraps a [temporal.TimeoutError]. +// The handler argument is ignored. func IsTimeoutError(err error, handler func(timeoutErr *temporal.TimeoutError)) bool { return AsTimeoutError(err) != nil } +// AsTimeoutError returns the first [temporal.TimeoutError] in err's chain, +// or nil if there is none. func AsTimeoutError(err error) *temporal.TimeoutError { var timeoutErr *temporal.TimeoutError if err != nil && errors.As(err, &timeoutErr) { @@ -64,10 +83,14 @@ func AsTimeoutError(err error) *temporal.TimeoutError { return nil } +// IsPanicError reports whether err wraps a [temporal.PanicError]. +// The handler argument is ignored. func IsPanicError(err error, handler func(panicErr *temporal.PanicError)) bool { return AsPanicError(err) != nil } +// AsPanicError returns the first [temporal.PanicError] in err's chain, or nil +// if there is none. func AsPanicError(err error) *temporal.PanicError { var panicErr *temporal.PanicError if err != nil && errors.As(err, &panicErr) { diff --git a/integration/temporal/log.go b/integration/temporal/log.go index d118d972..d92ff360 100644 --- a/integration/temporal/log.go +++ b/integration/temporal/log.go @@ -9,32 +9,40 @@ import ( "go.uber.org/zap" ) +// Error logs msg at error level on l with the given fields. The err argument +// is not included in the log entry. func Error(l tlog.Logger, err error, msg string, fields ...zap.Field) { LoggerWith(l, fields...).Error(msg) } +// Info logs msg at info level on l with the given fields. func Info(l tlog.Logger, msg string, fields ...zap.Field) { LoggerWith(l, fields...).Info(msg) } +// Warn logs msg at warn level on l with the given fields. func Warn(l tlog.Logger, msg string, fields ...zap.Field) { LoggerWith(l, fields...).Warn(msg) } +// Debug logs msg at debug level on l with the given fields. func Debug(l tlog.Logger, msg string, fields ...zap.Field) { LoggerWith(l, fields...).Debug(msg) } +// GetWorkflowLogger returns the workflow logger of ctx enriched with fields. func GetWorkflowLogger(ctx workflow.Context, fields ...zap.Field) tlog.Logger { l := workflow.GetLogger(ctx) return LoggerWith(l, fields...) } +// GetActivityLogger returns the activity logger of ctx enriched with fields. func GetActivityLogger(ctx context.Context, fields ...zap.Field) tlog.Logger { l := activity.GetLogger(ctx) return LoggerWith(l, fields...) } +// LoggerWith returns a logger that adds fields to every entry logged by l. func LoggerWith(l tlog.Logger, fields ...zap.Field) tlog.Logger { v := make([]any, len(fields)) for i, field := range fields { diff --git a/integration/temporal/logger.go b/integration/temporal/logger.go index 30d10a81..eeba87da 100644 --- a/integration/temporal/logger.go +++ b/integration/temporal/logger.go @@ -7,20 +7,27 @@ import ( "go.uber.org/zap" ) +// logger adapts a [zap.Logger] to the Temporal SDK logger interface. type logger struct { l *zap.Logger } +// NewLogger returns a Temporal SDK logger that writes to l. Key-value pairs +// are converted to zap fields with lower-cased keys, and zap fields are passed +// through unchanged. func NewLogger(l *zap.Logger) *logger { return &logger{ l: l.WithOptions(zap.AddCallerSkip(3)), } } +// Debug logs msg at debug level. func (t *logger) Debug(msg string, keyvals ...any) { t.l.Debug(msg, t.fields(keyvals...)...) } +// Info logs msg at info level, or at error level if any element of keyvals +// equals "Error". func (t *logger) Info(msg string, keyvals ...any) { // TODO check with temporal why errors are being logged as info! for _, keyval := range keyvals { @@ -33,18 +40,23 @@ func (t *logger) Info(msg string, keyvals ...any) { t.l.Info(msg, t.fields(keyvals...)...) } +// Warn logs msg at warn level. func (t *logger) Warn(msg string, keyvals ...any) { t.l.Warn(msg, t.fields(keyvals...)...) } +// Error logs msg at error level. func (t *logger) Error(msg string, keyvals ...any) { t.l.Error(msg, t.fields(keyvals...)...) } +// With returns a logger that adds keyvals to every entry. func (t *logger) With(keyvals ...any) *logger { return NewLogger(t.l.With(t.fields(keyvals...)...)) } +// fields converts Temporal key-value pairs into zap fields. A trailing key +// without a value is logged under the "undefined" key. func (t *logger) fields(keyvals ...any) []zap.Field { var fields []zap.Field diff --git a/integration/temporal/metrics.go b/integration/temporal/metrics.go index 5b9fef32..702e9dea 100644 --- a/integration/temporal/metrics.go +++ b/integration/temporal/metrics.go @@ -10,15 +10,22 @@ import ( "go.temporal.io/sdk/client" ) +// metricsHandler implements [client.MetricsHandler] on top of an +// OpenTelemetry meter. type metricsHandler struct { meter metric.Meter attr []attribute.KeyValue } +// NewMetricsHandler returns a Temporal metrics handler that records counters, +// gauges and timers with meter. Instrument creation errors are reported via +// [otel.Handle]. func NewMetricsHandler(meter metric.Meter) client.MetricsHandler { return metricsHandler{meter: meter} } +// WithTags returns a handler whose instruments record tags as string +// attributes. Tags replace any previously set tags. func (m metricsHandler) WithTags(tags map[string]string) client.MetricsHandler { attr := make([]attribute.KeyValue, 0, len(tags)) for k, v := range tags { @@ -28,15 +35,18 @@ func (m metricsHandler) WithTags(tags map[string]string) client.MetricsHandler { return metricsHandler{meter: m.meter, attr: attr} } +// counter adapts an Int64Counter to [client.MetricsCounter]. type counter struct { inst metric.Int64Counter attr []attribute.KeyValue } +// Inc adds v to the counter. func (c *counter) Inc(v int64) { c.inst.Add(context.Background(), v, metric.WithAttributes(c.attr...)) } +// Counter returns an Int64Counter instrument with the given name. func (m metricsHandler) Counter(name string) client.MetricsCounter { c, err := m.meter.Int64Counter(name) if err != nil { @@ -49,15 +59,19 @@ func (m metricsHandler) Counter(name string) client.MetricsCounter { } } +// gauge adapts a Float64ObservableGauge to [client.MetricsGauge]; the last +// updated value is reported on each collection. type gauge struct { inst metric.Float64ObservableGauge value float64 } +// Update sets the value reported by the gauge. func (c *gauge) Update(v float64) { c.value = v } +// Gauge returns a Float64ObservableGauge instrument with the given name. func (m metricsHandler) Gauge(name string) client.MetricsGauge { c, err := m.meter.Float64ObservableGauge(name) if err != nil { @@ -79,15 +93,19 @@ func (m metricsHandler) Gauge(name string) client.MetricsGauge { return inst } +// timer adapts an Int64Histogram to [client.MetricsTimer]. type timer struct { inst metric.Int64Histogram attr []attribute.KeyValue } +// Record records v in milliseconds. func (c *timer) Record(v time.Duration) { c.inst.Record(context.TODO(), v.Milliseconds(), metric.WithAttributes(c.attr...)) } +// Timer returns an Int64Histogram instrument with the given name that records +// durations in milliseconds. func (m metricsHandler) Timer(name string) client.MetricsTimer { c, err := m.meter.Int64Histogram(name) if err != nil { diff --git a/integration/temporal/service.go b/integration/temporal/service.go index 20ff3b80..767d7ab6 100644 --- a/integration/temporal/service.go +++ b/integration/temporal/service.go @@ -9,6 +9,7 @@ import ( "github.com/foomo/keel/log" ) +// service runs a Temporal worker as a keel service. type service struct { l *zap.Logger w worker.Worker @@ -19,6 +20,8 @@ type service struct { // ~ Constructor // ------------------------------------------------------------------------------------------------ +// NewService returns a keel service named name that starts w on Start and +// stops it on Close. If l is nil, the keel default logger is used. func NewService(l *zap.Logger, name string, w worker.Worker) *service { if l == nil { l = log.Logger() @@ -33,6 +36,7 @@ func NewService(l *zap.Logger, name string, w worker.Worker) *service { // ~ Getter // ------------------------------------------------------------------------------------------------ +// Name returns the service name. func (s *service) Name() string { return s.name } @@ -41,11 +45,13 @@ func (s *service) Name() string { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Start starts the worker without blocking and returns its start error. func (s *service) Start(ctx context.Context) error { s.l.Info("starting temporal worker") return s.w.Start() } +// Close stops the worker. It always returns nil. func (s *service) Close(ctx context.Context) error { s.l.Info("stopping temporal worker") s.w.Stop() diff --git a/integration/temporal/testservice.go b/integration/temporal/testservice.go index b9080029..bf33bc96 100644 --- a/integration/temporal/testservice.go +++ b/integration/temporal/testservice.go @@ -6,6 +6,7 @@ import ( "go.temporal.io/sdk/worker" ) +// test runs a Temporal worker as a keel service in tests. type test struct { w worker.Worker name string @@ -15,6 +16,9 @@ type test struct { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// NewTestService returns a keel service named name that starts w on Start and +// stops it on Close. Unlike [NewService] it does not log and ignores worker +// start errors. func NewTestService(name string, w worker.Worker) *test { return &test{name: name, w: w} } @@ -23,10 +27,12 @@ func NewTestService(name string, w worker.Worker) *test { // ~ Getter // ------------------------------------------------------------------------------------------------ +// URL returns an empty string. func (s *test) URL() string { return "" } +// Name returns the service name. func (s *test) Name() string { return s.name } @@ -35,11 +41,13 @@ func (s *test) Name() string { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Start starts the worker without blocking. It always returns nil. func (s *test) Start(ctx context.Context) error { _ = s.w.Start() return nil } +// Close stops the worker. It always returns nil. func (s *test) Close(ctx context.Context) error { s.w.Stop() return nil diff --git a/internal/otel/doc.go b/internal/otel/doc.go new file mode 100644 index 00000000..a3af5cb5 --- /dev/null +++ b/internal/otel/doc.go @@ -0,0 +1,3 @@ +// Package otel provides adapters that route OpenTelemetry's internal logging +// and error reporting to zap. +package otel diff --git a/internal/otel/errorhandler.go b/internal/otel/errorhandler.go index 26bd1ee9..4dad1099 100644 --- a/internal/otel/errorhandler.go +++ b/internal/otel/errorhandler.go @@ -6,14 +6,21 @@ import ( "github.com/foomo/keel/log" ) +// ErrorHandler is an [otel.ErrorHandler] that logs OpenTelemetry errors to +// a zap logger. Errors with the message "not implemented yet" are logged at +// warn level, all others at error level; nil errors are ignored. +// +// [otel.ErrorHandler]: https://pkg.go.dev/go.opentelemetry.io/otel#ErrorHandler type ErrorHandler struct { l *zap.Logger } +// NewErrorHandler returns an [ErrorHandler] logging to l. func NewErrorHandler(l *zap.Logger) *ErrorHandler { return &ErrorHandler{l} } +// Handle logs err. func (h *ErrorHandler) Handle(err error) { l := log.WithError(h.l, err) if err != nil && err.Error() == "not implemented yet" { @@ -23,6 +30,8 @@ func (h *ErrorHandler) Handle(err error) { } } +// SetLogger replaces the logger. It is not safe for concurrent use with +// [ErrorHandler.Handle]. func (h *ErrorHandler) SetLogger(l *zap.Logger) { h.l = l } diff --git a/internal/otel/exporters/zap/doc.go b/internal/otel/exporters/zap/doc.go new file mode 100644 index 00000000..4e469259 --- /dev/null +++ b/internal/otel/exporters/zap/doc.go @@ -0,0 +1,3 @@ +// Package zap provides an OpenTelemetry log exporter that writes log records +// to a [go.uber.org/zap.Logger]. +package zap diff --git a/internal/otel/exporters/zap/exporter.go b/internal/otel/exporters/zap/exporter.go index 00dff104..d834f81b 100644 --- a/internal/otel/exporters/zap/exporter.go +++ b/internal/otel/exporters/zap/exporter.go @@ -11,16 +11,19 @@ import ( "go.uber.org/zap/zapcore" ) +// Exporter implements [sdklog.Exporter]. var _ sdklog.Exporter = &Exporter{} -// Exporter writes JSON-encoded log records to an [io.Writer] ([os.Stdout] by default). -// Exporter must be created with [New]. +// Exporter writes OpenTelemetry log records to a zap logger, mapping the +// severity to a zap level and attributes, trace and span IDs to fields. It is +// safe for concurrent use. Exporter must be created with [New]. type Exporter struct { logger *zap.Logger shutdown atomic.Bool } -// New creates an [Exporter]. +// New returns an [Exporter] writing to logger. The returned error is always +// nil. func New(logger *zap.Logger) (*Exporter, error) { e := Exporter{ logger: logger, @@ -29,7 +32,8 @@ func New(logger *zap.Logger) (*Exporter, error) { return &e, nil } -// Export exports log records to writer. +// Export writes records to the logger. It stops and returns the context +// error if ctx is done, and does nothing after [Exporter.Shutdown]. func (e *Exporter) Export(ctx context.Context, records []sdklog.Record) error { if e.shutdown.Load() { return nil @@ -59,6 +63,7 @@ func (e *Exporter) ForceFlush(context.Context) error { return nil } +// export writes a single record to the logger. func (e *Exporter) export(r sdklog.Record) { var fields []zap.Field @@ -94,6 +99,9 @@ func (e *Exporter) export(r sdklog.Record) { e.logger.Log(convertLevel(r.Severity()), r.Body().String(), fields...) } +// convertLevel maps an OpenTelemetry severity to a zap level. Only the base +// severities (e.g. SeverityInfo, not SeverityInfo2) are mapped; all others +// return zapcore.InvalidLevel. func convertLevel(level log.Severity) zapcore.Level { switch level { case log.SeverityDebug: diff --git a/internal/otel/logger.go b/internal/otel/logger.go index 706cb33a..6acbaff6 100644 --- a/internal/otel/logger.go +++ b/internal/otel/logger.go @@ -10,37 +10,49 @@ import ( "github.com/foomo/keel/log" ) +// Logger is a [logr.LogSink] writing to a zap logger, used as the +// OpenTelemetry internal logger. type Logger struct { l *zap.Logger } +// NewLogger returns a [Logger] writing to l. func NewLogger(l *zap.Logger) Logger { return Logger{l: l} } +// Init implements [logr.LogSink] and does nothing. func (l Logger) Init(info logr.RuntimeInfo) { } +// Enabled reports whether the logr verbosity level, mapped to the negated +// zap level, is enabled by the global keel log level. func (l Logger) Enabled(level int) bool { return log.AtomicLevel().Enabled(zapcore.Level(-1 * level)) //nolint:gosec } +// Info logs msg at zap info level regardless of level. func (l Logger) Info(level int, msg string, keysAndValues ...any) { l.l.Info(msg, l.fields(keysAndValues)...) } +// Error logs msg at zap error level. err is not included in the entry. func (l Logger) Error(err error, msg string, keysAndValues ...any) { l.l.Error(msg, l.fields(keysAndValues)...) } +// WithValues returns a sink with keysAndValues added as fields. func (l Logger) WithValues(keysAndValues ...any) logr.LogSink { return NewLogger(l.l.With(l.fields(keysAndValues)...)) } +// WithName returns a sink with name appended to the logger name. func (l Logger) WithName(name string) logr.LogSink { return NewLogger(l.l.Named(name)) } +// fields converts alternating keys and values to zap fields. It expects an +// even number of elements. func (l Logger) fields(keysAndValues []any) []zap.Field { ret := make([]zap.Field, 0, len(keysAndValues)/2) for i := 0; i < len(keysAndValues); i += 2 { diff --git a/internal/pyroscope/doc.go b/internal/pyroscope/doc.go new file mode 100644 index 00000000..c02be25f --- /dev/null +++ b/internal/pyroscope/doc.go @@ -0,0 +1,2 @@ +// Package pyroscope provides a logger adapter for the pyroscope client. +package pyroscope diff --git a/internal/pyroscope/logger.go b/internal/pyroscope/logger.go index e85cc967..ebf1f0dc 100644 --- a/internal/pyroscope/logger.go +++ b/internal/pyroscope/logger.go @@ -6,22 +6,27 @@ import ( "github.com/go-logr/logr" ) +// Logger adapts a [logr.Logger] to the pyroscope client logger interface. type Logger struct { l logr.Logger } +// NewLogger returns a [Logger] writing to l. func NewLogger(l logr.Logger) *Logger { return &Logger{l: l} } +// Infof logs a formatted message at verbosity 3. func (l *Logger) Infof(format string, a ...any) { l.l.V(3).Info("[Info] " + fmt.Sprintf(format, a...)) } +// Debugf logs a formatted message at verbosity 4. func (l *Logger) Debugf(format string, a ...any) { l.l.V(4).Info("[Debug] " + fmt.Sprintf(format, a...)) } +// Errorf logs a formatted message at verbosity 0 as an info entry. func (l *Logger) Errorf(format string, a ...any) { l.l.V(0).Info("[Error] " + fmt.Sprintf(format, a...)) } diff --git a/internal/sync/bufpool.go b/internal/sync/bufpool.go index 61ec9119..c05567f5 100644 --- a/internal/sync/bufpool.go +++ b/internal/sync/bufpool.go @@ -5,6 +5,7 @@ import ( "sync" ) +// pool holds reusable [bytes.Buffer] values. var pool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } diff --git a/internal/sync/doc.go b/internal/sync/doc.go new file mode 100644 index 00000000..58c29a88 --- /dev/null +++ b/internal/sync/doc.go @@ -0,0 +1,4 @@ +// Package sync provides a pool of reusable [bytes.Buffer] values. +// +// The pool is safe for concurrent use. +package sync diff --git a/job.go b/job.go index 27fa21f6..3444f8bc 100644 --- a/job.go +++ b/job.go @@ -43,8 +43,8 @@ type ( // Job runs a keel workload to completion, as suited for a Kubernetes Job. // -// Unlike Server (which blocks until a shutdown signal), a Job runs its steps in -// order, then exits: RunE returns the step error (or nil), and Run translates that +// Unlike [Server] (which blocks until a shutdown signal), a Job runs its steps in +// order, then exits: [Job.RunE] returns the step error (or nil), and [Job.Run] translates that // into a process exit code so Kubernetes can apply backoffLimit. SIGINT/SIGTERM are // treated as an abnormal interruption that cancels the running step. No init HTTP // services are started; metrics are pushed/flushed on exit rather than exposed. @@ -66,9 +66,9 @@ type Job struct { c *viper.Viper } -// NewJob creates a new Job with the given options. The job name defaults to the -// OTEL_SERVICE_NAME (falling back to telemetry.DefaultServiceName) and can be -// overridden with JobWithName. It is used as the root routine/span label, the +// NewJob creates a [Job] configured by opts. The job name defaults to +// OTEL_SERVICE_NAME (falling back to [telemetry.DefaultServiceName]) and can be +// overridden with [JobWithName]. It is used as the root routine/span label, the // Pushgateway group, and a log field. func NewJob(opts ...JobOption) *Job { inst := &Job{ @@ -129,22 +129,25 @@ func (j *Job) Context() context.Context { return j.ctx } -// Meter returns the job meter. +// Meter returns the global OpenTelemetry meter from [telemetry.Meter]. func (j *Job) Meter() metric.Meter { return telemetry.Meter() } -// Tracer returns the job tracer. +// Tracer returns the global OpenTelemetry tracer from [telemetry.Tracer]. func (j *Job) Tracer() trace.Tracer { return telemetry.Tracer() } -// AddStep adds a step to be run in registration order. +// AddStep adds a step to be run in registration order. It is not safe for +// concurrent use. func (j *Job) AddStep(name string, fn StepFn) { j.steps = append(j.steps, jobStep{name: name, fn: fn}) } -// AddCloser registers a closer to be called during job finalization. +// AddCloser registers closer to be called during job finalization. Values that +// do not satisfy [IsCloser] are ignored with a warning; adding the same closer +// twice is a no-op. func (j *Job) AddCloser(closer any) { if !IsCloser(closer) { j.l.Warn("unable to add closer", log.FValue(fmt.Sprintf("%T", closer))) @@ -158,7 +161,7 @@ func (j *Job) AddCloser(closer any) { j.syncClosers = append(j.syncClosers, closer) } -// AddClosers registers the given closers to be called during job finalization. +// AddClosers calls [Job.AddCloser] for each of closers. func (j *Job) AddClosers(closers ...any) { for _, closer := range closers { j.AddCloser(closer) @@ -166,7 +169,7 @@ func (j *Job) AddClosers(closers ...any) { } // Run executes the job and exits the process: 0 on success, 1 on failure. This is -// the convenience entrypoint for a job's main(); use RunE if you need to handle the +// the convenience entrypoint for a job's main(); use [Job.RunE] if you need to handle the // error yourself. func (j *Job) Run() { if err := j.RunE(); err != nil { @@ -225,7 +228,7 @@ func (j *Job) run(ctx context.Context) error { return err } -// runSteps runs the steps sequentially (default) or concurrently (JobWithParallel). +// runSteps runs the steps sequentially (default) or concurrently ([JobWithParallel]). func (j *Job) runSteps(ctx context.Context) error { if j.parallel { g := gofuncy.NewGroup(ctx, diff --git a/joboption.go b/joboption.go index d968a957..d472f770 100644 --- a/joboption.go +++ b/joboption.go @@ -12,41 +12,41 @@ import ( "go.uber.org/zap" ) -// JobOption func +// JobOption configures a [Job] in [NewJob]. type JobOption func(inst *Job) -// JobWithLogger option +// JobWithLogger sets the job logger. Defaults to [log.Logger]. func JobWithLogger(l *zap.Logger) JobOption { return func(inst *Job) { inst.l = l } } -// JobWithLogFields option +// JobWithLogFields adds fields to the job logger. func JobWithLogFields(fields ...zap.Field) JobOption { return func(inst *Job) { inst.l = inst.l.With(fields...) } } -// JobWithName option overrides the default job name (OTEL_SERVICE_NAME, falling -// back to telemetry.DefaultServiceName). +// JobWithName overrides the default job name (OTEL_SERVICE_NAME, falling back +// to [telemetry.DefaultServiceName]). func JobWithName(name string) JobOption { return func(inst *Job) { inst.name = name } } -// JobWithConfig option +// JobWithConfig sets the job configuration. Defaults to [config.Config]. func JobWithConfig(c *viper.Viper) JobOption { return func(inst *Job) { inst.c = c } } -// JobWithParallel option runs the job steps concurrently instead of sequentially. +// JobWithParallel runs the job steps concurrently instead of sequentially. // limit caps the number of steps running at once; limit <= 0 means unbounded. The -// first failing step cancels the rest (fail-fast) and RunE returns the joined error. +// first failing step cancels the rest (fail-fast) and [Job.RunE] returns the joined error. func JobWithParallel(limit int) JobOption { return func(inst *Job) { inst.parallel = true @@ -54,29 +54,32 @@ func JobWithParallel(limit int) JobOption { } } -// JobWithContext option +// JobWithContext sets the parent of the job context. Defaults to +// [context.Background]. func JobWithContext(ctx context.Context) JobOption { return func(inst *Job) { inst.ctx = ctx } } -// JobWithShutdownSignals option +// JobWithShutdownSignals sets the signals that interrupt the job. Defaults to +// SIGINT and SIGTERM. func JobWithShutdownSignals(shutdownSignals ...os.Signal) JobOption { return func(inst *Job) { inst.shutdownSignals = shutdownSignals } } -// JobWithGracefulPeriod option sets the budget for flushing telemetry and closing -// resources after the job completes or is interrupted. +// JobWithGracefulPeriod sets the budget for flushing telemetry and closing +// resources after the job completes or is interrupted. Defaults to +// KEEL_GRACEFUL_PERIOD seconds, or 30 seconds. func JobWithGracefulPeriod(gracefulPeriod time.Duration) JobOption { return func(inst *Job) { inst.gracefulPeriod = gracefulPeriod } } -// JobWithTimeout option sets an in-process deadline for the whole job run. +// JobWithTimeout sets an in-process deadline for the whole job run. // A zero duration (default) disables the in-process deadline; Kubernetes // activeDeadlineSeconds still applies via SIGTERM handling. func JobWithTimeout(timeout time.Duration) JobOption { @@ -85,14 +88,15 @@ func JobWithTimeout(timeout time.Duration) JobOption { } } -// JobWithCloser option registers a closer to be called during job finalization. +// JobWithCloser registers closer to be called during job finalization. See +// [Job.AddCloser]. func JobWithCloser(closer any) JobOption { return func(inst *Job) { inst.AddCloser(closer) } } -// JobWithTelemetry option wires the OpenTelemetry trace, metric and logger providers +// JobWithTelemetry wires the OpenTelemetry trace, metric and logger providers // from the standard OTEL environment variables: // // OTEL_TRACES_EXPORTER none(default) | console | otlp @@ -102,7 +106,7 @@ func JobWithCloser(closer any) JobOption { // (per-signal override: OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL) // // A signal set to none leaves its provider unset, so the job falls back to a no-op -// provider. Call this before JobWithPushgatewayMeter so its nil meter-provider guard +// provider. Call this before [JobWithPushgatewayMeter] so its nil meter-provider guard // still fires when OTEL_METRICS_EXPORTER is none. func JobWithTelemetry() JobOption { return func(inst *Job) { @@ -129,7 +133,8 @@ func JobWithTelemetry() JobOption { } } -// JobWithStdOutTracer option with default value. +// JobWithStdOutTracer sets a trace provider that writes to stdout. enabled is +// the default for the otel.enabled config key. func JobWithStdOutTracer(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -141,7 +146,8 @@ func JobWithStdOutTracer(enabled bool) JobOption { } } -// JobWithStdOutMeter option with default value. +// JobWithStdOutMeter sets a meter provider that writes to stdout. enabled is +// the default for the otel.enabled config key. func JobWithStdOutMeter(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -153,7 +159,8 @@ func JobWithStdOutMeter(enabled bool) JobOption { } } -// JobWithOTLPGRPCTracer option with default value. +// JobWithOTLPGRPCTracer sets an OTLP gRPC trace provider. enabled is the +// default for the otel.enabled config key. func JobWithOTLPGRPCTracer(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -165,7 +172,8 @@ func JobWithOTLPGRPCTracer(enabled bool) JobOption { } } -// JobWithOTLPHTTPTracer option with default value. +// JobWithOTLPHTTPTracer sets an OTLP HTTP trace provider. enabled is the +// default for the otel.enabled config key. func JobWithOTLPHTTPTracer(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -177,7 +185,8 @@ func JobWithOTLPHTTPTracer(enabled bool) JobOption { } } -// JobWithOTLPGRPCMeter option with default value. Metrics are pushed via OTLP gRPC +// JobWithOTLPGRPCMeter sets an OTLP gRPC meter provider. enabled is the default +// for the otel.enabled config key. Metrics are pushed via OTLP gRPC // and flushed on job exit, suiting jobs that finish before a Prometheus scrape. func JobWithOTLPGRPCMeter(enabled bool) JobOption { return func(inst *Job) { @@ -190,7 +199,8 @@ func JobWithOTLPGRPCMeter(enabled bool) JobOption { } } -// JobWithOTLPHTTPMeter option with default value. Metrics are pushed via OTLP HTTP +// JobWithOTLPHTTPMeter sets an OTLP HTTP meter provider. enabled is the default +// for the otel.enabled config key. Metrics are pushed via OTLP HTTP // and flushed on job exit, suiting jobs that finish before a Prometheus scrape. func JobWithOTLPHTTPMeter(enabled bool) JobOption { return func(inst *Job) { @@ -203,7 +213,8 @@ func JobWithOTLPHTTPMeter(enabled bool) JobOption { } } -// JobWithOTLPHTTPLogger option with default value +// JobWithOTLPHTTPLogger sets an OTLP HTTP logger provider. enabled is the +// default for the otel.enabled config key. func JobWithOTLPHTTPLogger(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -215,7 +226,8 @@ func JobWithOTLPHTTPLogger(enabled bool) JobOption { } } -// JobWithOTLPGRCPLogger option with default value +// JobWithOTLPGRCPLogger sets an OTLP gRPC logger provider. enabled is the +// default for the otel.enabled config key. func JobWithOTLPGRCPLogger(enabled bool) JobOption { return func(inst *Job) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -227,10 +239,11 @@ func JobWithOTLPGRCPLogger(enabled bool) JobOption { } } -// JobWithPushgatewayMeter option pushes Prometheus metrics to a Pushgateway on job -// exit. An empty url disables it; the url falls back to the KEEL_PUSHGATEWAY_URL -// config/env value. It sets up a Prometheus meter provider so OTEL metrics are -// included in the push. +// JobWithPushgatewayMeter pushes Prometheus metrics to a Pushgateway on job +// exit, grouped by the job name. url is the default for the +// service.pushgateway.url config key (env SERVICE_PUSHGATEWAY_URL); an empty +// result disables it. It sets up a Prometheus meter provider (unless one is +// already configured) so OTEL metrics are included in the push. func JobWithPushgatewayMeter(url string) JobOption { return func(inst *Job) { url = config.GetString(inst.Config(), "service.pushgateway.url", url)() diff --git a/jwt/doc.go b/jwt/doc.go new file mode 100644 index 00000000..e0e95df5 --- /dev/null +++ b/jwt/doc.go @@ -0,0 +1,13 @@ +// Package jwt provides helpers for signing and verifying RS256 JSON Web +// Tokens on top of github.com/golang-jwt/jwt/v5, including support for key +// rotation via deprecated verification keys. +// +// A [Key] pairs an RSA public key with an optional private key and an +// identifier that is written to and read from the token's "kid" header. +// [JWT] signs tokens with its current key and verifies them using +// [DefaultKeyFunc] unless a custom key function is supplied: +// +// key, deprecated, err := jwt.NewKeysFromFilenames("public.pem", "private.pem", nil) +// j := jwt.New(key, jwt.WithDeprecatedKeys(deprecated...)) +// token, err := j.GetSignedToken(jwt.NewRegisteredClaimsWithLifetime(time.Hour)) +package jwt diff --git a/jwt/jwt.go b/jwt/jwt.go index c4f471ea..4a0c81f5 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -5,25 +5,30 @@ import ( ) type ( + // JWT signs and parses RS256 tokens. Create it with [New]. JWT struct { - // key for signing + // Key is the current key used for signing and verification. Key Key - // KeyFunc provider + // KeyFunc resolves the verification key for a parsed token. KeyFunc jwt.Keyfunc - // DeprecatedKeys e.g. due to rotation + // DeprecatedKeys holds verification-only keys indexed by key ID, + // e.g. kept after a key rotation. DeprecatedKeys map[string]Key } + // Option configures a [JWT]. Option func(*JWT) ) -// WithKeyFun middleware option +// WithKeyFun sets the key function used to verify tokens. Defaults to +// [DefaultKeyFunc] over the current and deprecated keys. func WithKeyFun(v jwt.Keyfunc) Option { return func(o *JWT) { o.KeyFunc = v } } -// WithDeprecatedKeys middleware option +// WithDeprecatedKeys adds keys that are still accepted for verification, +// indexed by their [Key.ID]. func WithDeprecatedKeys(v ...Key) Option { return func(o *JWT) { if len(v) > 0 { @@ -38,7 +43,9 @@ func WithDeprecatedKeys(v ...Key) Option { } } -// New returns a new JWT for the given key and optional old keys e.g. due to rotation +// New returns a [JWT] that signs with key. If no key function is set via +// [WithKeyFun], [DefaultKeyFunc] is used with key and any keys added via +// [WithDeprecatedKeys]. Nil options are ignored. func New(key Key, opts ...Option) *JWT { inst := &JWT{ Key: key, @@ -57,6 +64,9 @@ func New(key Key, opts ...Option) *JWT { return inst } +// GetSignedToken returns claims as a token signed with RS256 using the +// private part of [JWT.Key], with the "kid" header set to its ID. It +// returns an error if signing fails, e.g. when no private key is set. func (j *JWT) GetSignedToken(claims jwt.Claims) (string, error) { // create token token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) @@ -65,6 +75,8 @@ func (j *JWT) GetSignedToken(claims jwt.Claims) (string, error) { return token.SignedString(j.Key.Private) } +// ParseWithClaims parses and validates token into claims, resolving the +// verification key with [JWT.KeyFunc]. func (j *JWT) ParseWithClaims(token string, claims jwt.Claims) (*jwt.Token, error) { return jwt.ParseWithClaims(token, claims, j.KeyFunc) } diff --git a/jwt/jwtclaims.go b/jwt/jwtclaims.go index 8977ecf0..b4f93139 100644 --- a/jwt/jwtclaims.go +++ b/jwt/jwtclaims.go @@ -8,37 +8,48 @@ import ( // MaxTimeDifferenceBetweenNodes represents an offset that should be taken // into account when creating e.g. jwt tokens with the `notBefore` flag. +// It is the default offset used by [NewRegisteredClaims]. var MaxTimeDifferenceBetweenNodes = 30 * time.Second -// Deprecated: NewStandardClaims use NewRegisteredClaims instead. +// NewStandardClaims returns registered claims using +// [MaxTimeDifferenceBetweenNodes] as offset. +// +// Deprecated: Use [NewRegisteredClaims] instead. func NewStandardClaims() jwt.RegisteredClaims { return NewRegisteredClaims( WithOffset(MaxTimeDifferenceBetweenNodes), ) } -// Deprecated: NewStandardClaimsWithLifetime use NewRegisteredClaimsWithLifetime instead. +// NewStandardClaimsWithLifetime returns registered claims expiring after +// lifetime, using [MaxTimeDifferenceBetweenNodes] as offset. +// +// Deprecated: Use [NewRegisteredClaimsWithLifetime] instead. func NewStandardClaimsWithLifetime(lifetime time.Duration) jwt.RegisteredClaims { return NewRegisteredClaimsWithLifetime(lifetime, WithOffset(MaxTimeDifferenceBetweenNodes)) } -// RegisteredClaimsOption configures how RegisteredClaims are created. +// RegisteredClaimsOption configures how [NewRegisteredClaims] creates claims. type RegisteredClaimsOption func(*registeredClaimsOptions) +// registeredClaimsOptions holds the settings applied by [RegisteredClaimsOption]. type registeredClaimsOptions struct { offset time.Duration } -// WithOffset sets the offset to account for time differences between nodes. +// WithOffset sets the offset subtracted from the current time to account for +// clock differences between nodes. Defaults to [MaxTimeDifferenceBetweenNodes]. func WithOffset(offset time.Duration) RegisteredClaimsOption { return func(o *registeredClaimsOptions) { o.offset = offset } } -// NewRegisteredClaims returns a new jwt.RegisteredClaims with the IssuedAt and NotBefore fields set to the current time plus the given offset. -// The offset can be used to account for time differences between nodes in a distributed system. -// If no offset option is provided, MaxTimeDifferenceBetweenNodes is used as the default. +// NewRegisteredClaims returns [jwt.RegisteredClaims] with IssuedAt and +// NotBefore set to the current time minus the offset. The offset accounts +// for clock differences between nodes in a distributed system; offsets under +// one millisecond are ignored. If no [WithOffset] option is provided, +// [MaxTimeDifferenceBetweenNodes] is used. func NewRegisteredClaims(opts ...RegisteredClaimsOption) jwt.RegisteredClaims { o := ®isteredClaimsOptions{offset: MaxTimeDifferenceBetweenNodes} for _, opt := range opts { @@ -56,7 +67,10 @@ func NewRegisteredClaims(opts ...RegisteredClaimsOption) jwt.RegisteredClaims { } } -// NewRegisteredClaimsWithLifetime returns a new jwt.RegisteredClaims with the IssuedAt and NotBefore fields set to the current time plus the given optional offset and the ExpiresAt field set to the current time plus the given lifetime. +// NewRegisteredClaimsWithLifetime returns claims as created by +// [NewRegisteredClaims] with ExpiresAt set to IssuedAt plus lifetime. Since +// IssuedAt is shifted back by the offset, the effective remaining lifetime +// is lifetime minus the offset. func NewRegisteredClaimsWithLifetime(lifetime time.Duration, opts ...RegisteredClaimsOption) jwt.RegisteredClaims { claims := NewRegisteredClaims(opts...) claims.ExpiresAt = jwt.NewNumericDate(claims.IssuedAt.Add(lifetime)) diff --git a/jwt/jwtkey.go b/jwt/jwtkey.go index e363c88b..b9a72740 100644 --- a/jwt/jwtkey.go +++ b/jwt/jwtkey.go @@ -12,16 +12,18 @@ import ( "github.com/pkg/errors" ) +// Key is an RSA key pair identified by ID. type Key struct { - // ID (required) represents the key identifier e.g. the md5 representation of the public key + // ID (required) is the key identifier used as the token's "kid" header, + // e.g. a hash of the public key. ID string - // Public (required) rsa key + // Public (required) is the RSA public key used for verification. Public *rsa.PublicKey - // Private (optional) rsa key + // Private (optional) is the RSA private key used for signing. Private *rsa.PrivateKey } -// NewKey return a new Key +// NewKey returns a [Key] with the given ID and keys. func NewKey(id string, public *rsa.PublicKey, private *rsa.PrivateKey) Key { return Key{ ID: id, @@ -30,7 +32,11 @@ func NewKey(id string, public *rsa.PublicKey, private *rsa.PrivateKey) Key { } } -// NewKeyFromFilenames returns a new Key from the given file names +// NewKeyFromFilenames loads a [Key] from PEM files. The private key is +// optional and skipped if privateKeyPemFilename is empty. Literal \n escape +// sequences in the file contents are converted to newlines before parsing. +// The key ID is the hex-encoded SHA-256 of the trimmed public key file +// contents. It returns an error if a file cannot be read or parsed. func NewKeyFromFilenames(publicKeyPemFilename, privateKeyPemFilename string) (Key, error) { var ( id string @@ -65,7 +71,8 @@ func NewKeyFromFilenames(publicKeyPemFilename, privateKeyPemFilename string) (Ke return NewKey(id, public, private), nil } -// NewDeprecatedKeysFromFilenames returns new Keys from the given file names +// NewDeprecatedKeysFromFilenames loads public-only keys from the given PEM +// files, for use with [WithDeprecatedKeys]. It returns the first load error. func NewDeprecatedKeysFromFilenames(publicKeyPemFilenames []string) ([]Key, error) { deprecatedKeys := make([]Key, 0, len(publicKeyPemFilenames)) for _, publicKeyPemFilename := range publicKeyPemFilenames { @@ -79,7 +86,8 @@ func NewDeprecatedKeysFromFilenames(publicKeyPemFilenames []string) ([]Key, erro return deprecatedKeys, nil } -// NewKeysFromFilenames helper +// NewKeysFromFilenames loads the current key via [NewKeyFromFilenames] and +// the deprecated keys via [NewDeprecatedKeysFromFilenames]. func NewKeysFromFilenames(publicKeyPemFilename, privateKeyPemFilename string, deprecatedPublicKeyPemFilenames []string) (Key, []Key, error) { key, err := NewKeyFromFilenames(publicKeyPemFilename, privateKeyPemFilename) if err != nil { diff --git a/jwt/keyfunc.go b/jwt/keyfunc.go index ef2f0683..2f019d0e 100644 --- a/jwt/keyfunc.go +++ b/jwt/keyfunc.go @@ -5,6 +5,10 @@ import ( "github.com/pkg/errors" ) +// DefaultKeyFunc returns a [jwt.Keyfunc] that accepts only RS256 tokens and +// selects the public key by the token's "kid" header, looking it up in +// deprecatedKeys first and then comparing it to key.ID. It returns an error +// for other signing methods and for missing, non-string or unknown key IDs. func DefaultKeyFunc(key Key, deprecatedKeys map[string]Key) jwt.Keyfunc { return func(token *jwt.Token) (any, error) { if token.Method.Alg() != jwt.SigningMethodRS256.Name { diff --git a/keeltest/assert/assert.go b/keeltest/assert/assert.go index d4e7cafc..130159d9 100644 --- a/keeltest/assert/assert.go +++ b/keeltest/assert/assert.go @@ -11,6 +11,10 @@ import ( "github.com/tidwall/pretty" ) +// InlineEqual compares the %v formatting of actual with the value stored +// as an INLINE comment on the calling line, see [keeltest.Inline]. A missing +// value is written and the test is marked as failed. It reports whether +// the values are equal. func InlineEqual(t *testing.T, actual any, msgAndArgs ...any) bool { t.Helper() @@ -22,6 +26,10 @@ func InlineEqual(t *testing.T, actual any, msgAndArgs ...any) bool { } } +// InlineJSONEq compares the JSON encoding of actual with the JSON stored +// as an INLINE comment on the calling line, ignoring formatting, see +// [keeltest.Inline]. A missing value is written and the test is marked as +// failed. It reports whether the values are equal. func InlineJSONEq(t *testing.T, actual any, msgAndArgs ...any) bool { t.Helper() // marshal value diff --git a/keeltest/assert/assertions.go b/keeltest/assert/assertions.go index b918df01..3351075a 100644 --- a/keeltest/assert/assertions.go +++ b/keeltest/assert/assertions.go @@ -11,19 +11,20 @@ import ( "github.com/tidwall/pretty" ) -// Assertions provides assertion methods around the -// TestingT interface. +// Assertions provides the package's inline assertions bound to a +// [testing.T]. type Assertions struct { t *testing.T } -// New makes a new Assertions object for the specified TestingT. +// New returns [Assertions] for t. func New(t *testing.T) *Assertions { //nolint:thelper return &Assertions{ t: t, } } +// InlineEqual is like the package level [InlineEqual]. func (a *Assertions) InlineEqual(actual any, msgAndArgs ...any) bool { a.t.Helper() @@ -35,6 +36,7 @@ func (a *Assertions) InlineEqual(actual any, msgAndArgs ...any) bool { } } +// InlineJSONEq is like the package level [InlineJSONEq]. func (a *Assertions) InlineJSONEq(actual any, msgAndArgs ...any) bool { a.t.Helper() // marshal value diff --git a/keeltest/assert/doc.go b/keeltest/assert/doc.go new file mode 100644 index 00000000..02a467d0 --- /dev/null +++ b/keeltest/assert/doc.go @@ -0,0 +1,4 @@ +// Package keelassert provides inline snapshot assertions in the style of +// [github.com/stretchr/testify/assert], see [keeltest.Inline]. Failed +// assertions mark the test as failed and continue. +package keelassert diff --git a/keeltest/client.go b/keeltest/client.go index ea7536c7..2015a17e 100644 --- a/keeltest/client.go +++ b/keeltest/client.go @@ -10,25 +10,32 @@ import ( ) type ( + // HTTPClient is an [http.Client] that prefixes request paths with + // BaseURL and returns response bodies as bytes. HTTPClient struct { http.Client BaseURL string } + // HTTPClientOption configures an [HTTPClient]. HTTPClientOption func(c *HTTPClient) ) +// HTTPClientWithCookieJar sets the cookie jar of the client. func HTTPClientWithCookieJar(v *cookiejar.Jar) HTTPClientOption { return func(c *HTTPClient) { c.Jar = v } } +// HTTPClientWithBaseURL sets the URL prefixed to all request paths. +// Defaults to an empty string. func HTTPClientWithBaseURL(v string) HTTPClientOption { return func(c *HTTPClient) { c.BaseURL = v } } +// NewHTTPClient returns an [HTTPClient] configured by opts. func NewHTTPClient(opts ...HTTPClientOption) *HTTPClient { inst := &HTTPClient{ Client: http.Client{}, @@ -42,6 +49,8 @@ func NewHTTPClient(opts ...HTTPClientOption) *HTTPClient { return inst } +// Get sends a GET request to BaseURL+path and returns the response body and +// status code. func (c *HTTPClient) Get(ctx context.Context, path string) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { @@ -62,6 +71,8 @@ func (c *HTTPClient) Get(ctx context.Context, path string) ([]byte, int, error) return body, resp.StatusCode, nil } +// Post sends data JSON encoded in a POST request to BaseURL+path and returns +// the response body and status code. func (c *HTTPClient) Post(ctx context.Context, path string, data any) ([]byte, int, error) { var req *http.Request diff --git a/keeltest/doc.go b/keeltest/doc.go new file mode 100644 index 00000000..16a858f7 --- /dev/null +++ b/keeltest/doc.go @@ -0,0 +1,20 @@ +// Package keeltest provides helpers for testing keel services. +// +// [Server] starts and closes [Service] instances such as [ServiceHTTP] +// within a test, [HTTPClient] sends requests against them, and [Inline] +// implements inline snapshot assertions that store expected values as +// trailing comments in the test source: +// +// svr := keeltest.NewServer(t) +// svr.AddService(keeltest.NewServiceHTTP(svr.Logger(), "demo", handler)) +// svr.Start() +// +// body, _, err := keeltest.NewHTTPClient( +// keeltest.HTTPClientWithBaseURL(svr.GetService("demo").URL()), +// ).Get(t.Context(), "/") +// require.NoError(t, err) +// keelassert.InlineEqual(t, string(body)) // INLINE: ok +// +// The packages [github.com/foomo/keel/keeltest/assert] and +// [github.com/foomo/keel/keeltest/require] provide the inline assertions. +package keeltest diff --git a/keeltest/inline.go b/keeltest/inline.go index 8a562baa..86647185 100644 --- a/keeltest/inline.go +++ b/keeltest/inline.go @@ -12,6 +12,15 @@ import ( "github.com/foomo/keel/log" ) +// Inline returns the expected value stored as a trailing " // INLINE: " +// comment on the source line of the caller skip frames up (1 is the caller +// of Inline), and true. +// +// If the line has no such comment, msgAndArgs is formatted with +// [fmt.Sprintf], appended to the line as an INLINE comment in the source +// file, the test is marked as failed, and Inline returns "" and false. +// Subsequent runs then compare against the written value. Missing +// msgAndArgs or file access errors abort the test with t.Fatal. func Inline(t *testing.T, skip int, msgAndArgs ...any) (string, bool) { t.Helper() @@ -57,6 +66,8 @@ func Inline(t *testing.T, skip int, msgAndArgs ...any) (string, bool) { return "", false } +// InlineInt is like [Inline] but parses the stored value as an int. It does +// not write missing values and aborts the test if none is stored. func InlineInt(t *testing.T, skip int) (int, bool) { t.Helper() @@ -70,6 +81,8 @@ func InlineInt(t *testing.T, skip int) (int, bool) { } } +// InlineFloat64 is like [Inline] but parses the stored value as a float64. +// It does not write missing values and aborts the test if none is stored. func InlineFloat64(t *testing.T, skip int) (float64, bool) { t.Helper() @@ -83,6 +96,9 @@ func InlineFloat64(t *testing.T, skip int) (float64, bool) { } } +// InlineJSON is like [Inline] but unmarshals the stored JSON value into +// target. It does not write missing values and aborts the test if none is +// stored. func InlineJSON(t *testing.T, skip int, target any) { t.Helper() diff --git a/keeltest/jwt.go b/keeltest/jwt.go index 8524399a..bac7c7f9 100644 --- a/keeltest/jwt.go +++ b/keeltest/jwt.go @@ -8,7 +8,8 @@ import ( "github.com/stretchr/testify/require" ) -// NewJWT returns a new JWT instance with test keys for testing purposes. +// NewJWT returns a [keeljwt.JWT] signing with a freshly generated RSA key +// pair. It fails the test on error. func NewJWT(t *testing.T) *keeljwt.JWT { t.Helper() diff --git a/keeltest/option.go b/keeltest/option.go index 160b4be1..26e60814 100644 --- a/keeltest/option.go +++ b/keeltest/option.go @@ -7,36 +7,42 @@ import ( "go.uber.org/zap" ) -// Option func +// Option configures a [Server]. type Option func(inst *Server) -// WithLogger option +// WithLogger sets the server logger. Defaults to a no-op logger. func WithLogger(l *zap.Logger) Option { return func(inst *Server) { inst.l = l } } -// WithLogFields option +// WithLogFields adds fields to the server logger. It must be applied after +// [WithLogger] to affect a custom logger. func WithLogFields(fields ...zap.Field) Option { return func(inst *Server) { inst.l = inst.l.With(fields...) } } -// WithConfig option +// WithConfig sets the server config. Defaults to [config.Config]. func WithConfig(c *viper.Viper) Option { return func(inst *Server) { inst.c = c } } +// WithMeterProvider sets the meter provider. Defaults to the global meter +// provider. It does not change the meter returned by [Server.Meter]. func WithMeterProvider(v metric.MeterProvider) Option { return func(inst *Server) { inst.meterProvider = v } } +// WithTracerProvider sets the tracer provider. Defaults to the global +// tracer provider. It does not change the tracer returned by +// [Server.Tracer]. func WithTracerProvider(v trace.TracerProvider) Option { return func(inst *Server) { inst.traceProvider = v diff --git a/keeltest/require/assertions.go b/keeltest/require/assertions.go index e43698cc..453dc684 100644 --- a/keeltest/require/assertions.go +++ b/keeltest/require/assertions.go @@ -11,19 +11,20 @@ import ( "github.com/tidwall/pretty" ) -// Assertions provides assertion methods around the -// TestingT interface. +// Assertions provides the package's inline assertions bound to a +// [testing.T]. type Assertions struct { t *testing.T } -// New makes a new Assertions object for the specified TestingT. +// New returns [Assertions] for t. func New(t *testing.T) *Assertions { //nolint:thelper return &Assertions{ t: t, } } +// InlineEqual is like the package level [InlineEqual]. func (a *Assertions) InlineEqual(actual any, msgAndArgs ...any) { a.t.Helper() @@ -32,6 +33,7 @@ func (a *Assertions) InlineEqual(actual any, msgAndArgs ...any) { } } +// InlineJSONEq is like the package level [InlineJSONEq]. func (a *Assertions) InlineJSONEq(actual any, msgAndArgs ...any) { a.t.Helper() // marshal value diff --git a/keeltest/require/doc.go b/keeltest/require/doc.go new file mode 100644 index 00000000..3a1aa8c8 --- /dev/null +++ b/keeltest/require/doc.go @@ -0,0 +1,4 @@ +// Package keelrequire provides inline snapshot assertions in the style of +// [github.com/stretchr/testify/require], see [keeltest.Inline]. Failed +// assertions stop the test. +package keelrequire diff --git a/keeltest/require/require.go b/keeltest/require/require.go index 1ee62c72..de8ae854 100644 --- a/keeltest/require/require.go +++ b/keeltest/require/require.go @@ -11,6 +11,10 @@ import ( "github.com/tidwall/pretty" ) +// InlineEqual compares the %v formatting of actual with the value stored +// as an INLINE comment on the calling line, see [keeltest.Inline]. A missing +// value is written and the test is marked as failed. It stops the test if +// the values differ. func InlineEqual(t *testing.T, actual any, msgAndArgs ...any) { t.Helper() @@ -19,6 +23,10 @@ func InlineEqual(t *testing.T, actual any, msgAndArgs ...any) { } } +// InlineJSONEq compares the JSON encoding of actual with the JSON stored +// as an INLINE comment on the calling line, ignoring formatting, see +// [keeltest.Inline]. A missing value is written and the test is marked as +// failed. It stops the test if the values differ. func InlineJSONEq(t *testing.T, actual any, msgAndArgs ...any) { t.Helper() // marshal value diff --git a/keeltest/server.go b/keeltest/server.go index b28071db..1165dd5b 100644 --- a/keeltest/server.go +++ b/keeltest/server.go @@ -15,6 +15,8 @@ import ( "go.uber.org/zap" ) +// Server is a lightweight test counterpart of the keel server that starts and +// closes [Service] instances within a test. Create one with [NewServer]. type Server struct { services []Service serviceMap map[string]Service @@ -27,6 +29,9 @@ type Server struct { c *viper.Viper } +// NewServer returns a [Server] using the context of tb, a no-op logger, the +// global config and the global telemetry providers unless overridden by +// opts. [Server.Close] is registered as cleanup on tb. func NewServer(tb testing.TB, opts ...Option) *Server { tb.Helper() @@ -49,43 +54,46 @@ func NewServer(tb testing.TB, opts ...Option) *Server { return inst } +// NewExampleServer returns a [Server] for use in Example functions. Its +// [Server.Close] must be called explicitly. func NewExampleServer(opts ...Option) *Server { return NewServer(testingx.NewExampleTB(), opts...) } -// Logger returns server logger +// Logger returns the server logger. func (s *Server) Logger() *zap.Logger { return s.l } -// Meter returns the implementation meter +// Meter returns the server meter. func (s *Server) Meter() metric.Meter { return s.meter } -// Tracer returns the implementation tracer +// Tracer returns the server tracer. func (s *Server) Tracer() trace.Tracer { return s.tracer } -// Config returns server config +// Config returns the server config. func (s *Server) Config() *viper.Viper { return s.c } -// Context returns server context +// Context returns the server context. func (s *Server) Context() context.Context { return s.ctx } -// AddServices adds multiple service +// AddServices adds services, see [Server.AddService]. func (s *Server) AddServices(services ...Service) { for _, service := range services { s.AddService(service) } } -// AddService add a single service +// AddService adds service unless it was already added. Services must be +// added before [Server.Start]. func (s *Server) AddService(service Service) { if slices.Contains(s.services, service) { return @@ -94,6 +102,8 @@ func (s *Server) AddService(service Service) { s.services = append(s.services, service) } +// GetService returns the service named name, or nil if there is none or +// [Server.Start] has not been called. func (s *Server) GetService(name string) Service { if v, ok := s.serviceMap[name]; ok { return v @@ -102,7 +112,8 @@ func (s *Server) GetService(name string) Service { return nil } -// Start starts all registered services +// Start starts all added services in order. Start errors are logged, not +// returned. func (s *Server) Start() { s.serviceMap = make(map[string]Service, len(s.services)) for _, service := range s.services { diff --git a/keeltest/service.go b/keeltest/service.go index 326ef854..04379f86 100644 --- a/keeltest/service.go +++ b/keeltest/service.go @@ -4,7 +4,8 @@ import ( "context" ) -// Service interface +// Service is a service managed by a [Server]. URL returns the address the +// service is reachable at once started. type Service interface { URL() string Name() string diff --git a/keeltest/servicehttp.go b/keeltest/servicehttp.go index 068619d0..da9b0db1 100644 --- a/keeltest/servicehttp.go +++ b/keeltest/servicehttp.go @@ -14,13 +14,16 @@ import ( "github.com/foomo/keel/log" ) -// ServiceHTTP struct +// ServiceHTTP is a [Service] serving an [http.Handler] on an +// [httptest.Server] bound to a random local port. type ServiceHTTP struct { server *httptest.Server name string l *zap.Logger } +// NewServiceHTTP returns a [ServiceHTTP] named name serving handler wrapped +// in middlewares. A nil l defaults to the global keel logger. func NewServiceHTTP(l *zap.Logger, name string, handler http.Handler, middlewares ...keelhttp.Middleware) *ServiceHTTP { if l == nil { l = log.Logger() @@ -38,18 +41,24 @@ func NewServiceHTTP(l *zap.Logger, name string, handler http.Handler, middleware } } +// Name returns the service name. func (s *ServiceHTTP) Name() string { return s.name } +// Logger returns the service logger. func (s *ServiceHTTP) Logger() *zap.Logger { return s.l } +// URL returns the base URL of the server. It is empty until +// [ServiceHTTP.Start] is called. func (s *ServiceHTTP) URL() string { return s.server.URL } +// Start starts the server with ctx as the base context of all requests. It +// always returns nil. func (s *ServiceHTTP) Start(ctx context.Context) error { var fields []zap.Field @@ -69,6 +78,8 @@ func (s *ServiceHTTP) Start(ctx context.Context) error { return nil } +// Close shuts down the server and blocks until all outstanding requests +// completed. It always returns nil. func (s *ServiceHTTP) Close(_ context.Context) error { s.l.Info("stopping http test service") s.server.Close() diff --git a/ldflags.go b/ldflags.go index eab3f29e..7c43d664 100644 --- a/ldflags.go +++ b/ldflags.go @@ -1,10 +1,17 @@ package keel +// Build information, intended to be set at link time. var ( - // Version usage -ldflags "-X github.com/foomo/keel/server.Version=$VERSION" + // Version is the build version, set with: + // + // -ldflags "-X github.com/foomo/keel.Version=$VERSION" Version string - // GitCommit usage -ldflags "-X github.com/foomo/keel/server.GitCommit=$GIT_COMMIT" + // GitCommit is the build commit, set with: + // + // -ldflags "-X github.com/foomo/keel.GitCommit=$GIT_COMMIT" GitCommit string - // BuildTime usage -ldflags "-X 'github.com/foomo/keel/server.BuildTime=$(date -u '+%Y-%m-%d %H:%M:%S')'" + // BuildTime is the build time, set with: + // + // -ldflags "-X 'github.com/foomo/keel.BuildTime=$(date -u '+%Y-%m-%d %H:%M:%S')'" BuildTime string ) diff --git a/log/configure.go b/log/configure.go index e07a91a2..c76a6a25 100644 --- a/log/configure.go +++ b/log/configure.go @@ -6,10 +6,14 @@ import ( "go.uber.org/zap" ) +// Config is a builder that accumulates fields on a [zap.Logger]. Its methods +// modify the receiver and return it for chaining. Use [Configure] to create +// one. type Config struct { l *zap.Logger } +// Configure returns a [Config] starting from l, or from [Logger] if l is nil. func Configure(l *zap.Logger) *Config { if l == nil { l = Logger() @@ -18,20 +22,24 @@ func Configure(l *zap.Logger) *Config { return &Config{l: l.With()} } +// Logger returns the configured logger. func (c *Config) Logger() *zap.Logger { return c.l } +// Error adds err using [FError]. func (c *Config) Error(err error) *Config { c.l = c.l.With(FError(err)) return c } +// With adds fields. func (c *Config) With(fields ...zap.Field) *Config { c.l = c.l.With(fields...) return c } +// HTTPRequest adds the request fields of [WithHTTPRequest]. func (c *Config) HTTPRequest(r *http.Request) *Config { c.l = WithHTTPRequest(c.l, r) return c diff --git a/log/doc.go b/log/doc.go new file mode 100644 index 00000000..93ae91e8 --- /dev/null +++ b/log/doc.go @@ -0,0 +1,21 @@ +// Package log provides the zap logger setup used by keel together with +// helpers for building structured log fields. +// +// On initialization the package installs a zap global logger configured from +// the LOG_LEVEL (default "info") and LOG_FORMAT ("json" or "console", default +// "json") environment variables; see [NewLogger]. [Logger] returns it and +// [AtomicLevel] allows changing its level at runtime. +// +// # Fields +// +// F* functions create single [zap.Field] values with well-known keys, and +// [Attribute] and [Attributes] convert OpenTelemetry attributes to fields. +// With* functions return a child logger with fields added, e.g. from an +// [net/http.Request]: +// +// l := log.WithHTTPRequest(nil, r) +// l.Info("handled", log.FDuration(time.Since(start))) +// +// A [Labeler] stored in a context collects fields that are added while a +// request is processed. +package log diff --git a/log/fields.go b/log/fields.go index c8afdaff..bdcfa5fb 100644 --- a/log/fields.go +++ b/log/fields.go @@ -7,15 +7,12 @@ import ( "go.uber.org/zap" ) +// Generic log field keys. const ( - // NumKey - generic number attribute - NumKey = "num" - // NameKey - generic name attribute - NameKey = "name" - // ValueKey - generic value attribute + NumKey = "num" + NameKey = "name" ValueKey = "value" - // JSONKey - generic json attribute - JSONKey = "json" + JSONKey = "json" ) // FNum creates a zap.Field with a given number under the key "num". @@ -28,12 +25,13 @@ func FName(name string) zap.Field { return zap.String(NameKey, name) } -// FValue creates a zap.Field with a given value under the key "value". +// FValue creates a zap.Field with value formatted by %v under the key "value". func FValue(value any) zap.Field { return zap.String(ValueKey, fmt.Sprintf("%v", value)) } -// FJSON creates a zap.Field with a given value under the key "json". +// FJSON creates a zap.Field with v marshaled as raw JSON under the key "json". +// If marshaling fails, the error message is logged under "json_error" instead. func FJSON(v any) zap.Field { if out, err := json.Marshal(v); err != nil { return zap.String(JSONKey+"_error", err.Error()) diff --git a/log/fields_code.go b/log/fields_code.go index 43ed9768..45ee315d 100644 --- a/log/fields_code.go +++ b/log/fields_code.go @@ -5,32 +5,48 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // CodeInstanceKey is the log field key "code_instance". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. CodeInstanceKey = "code_instance" - // Deprecated: use semconv messaging attributes instead. + // CodePackageKey is the log field key "code_package". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. CodePackageKey = "code_package" - // Deprecated: use semconv messaging attributes instead. + // CodeMethodKey is the log field key "code_method". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. CodeMethodKey = "code_method" - // Deprecated: use semconv messaging attributes instead. + // CodeLineKey is the log field key "code_line". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. CodeLineKey = "code_line" ) -// Deprecated: use semconv messaging attributes instead. +// FCodeInstance returns a field with the given value under [CodeInstanceKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FCodeInstance(v string) zap.Field { return zap.String(CodeInstanceKey, v) } -// Deprecated: use semconv messaging attributes instead. +// FCodePackage returns a field with the given value under [CodePackageKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FCodePackage(v string) zap.Field { return zap.String(CodePackageKey, v) } -// Deprecated: use semconv messaging attributes instead. +// FCodeMethod returns a field with the given value under [CodeMethodKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FCodeMethod(v string) zap.Field { return zap.String(CodeMethodKey, v) } -// Deprecated: use semconv messaging attributes instead. +// FCodeLine returns a field with the given value under [CodeLineKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FCodeLine(v int) zap.Field { return zap.Int(CodeLineKey, v) } diff --git a/log/fields_duration.go b/log/fields_duration.go index 5f7e3a88..cceed512 100644 --- a/log/fields_duration.go +++ b/log/fields_duration.go @@ -7,13 +7,13 @@ import ( ) const ( - // DurationKey - generic duration attribute + // DurationKey is the log field key for a duration in milliseconds. DurationKey = "duration" - // DurationSecKey - duration in seconds + // DurationSecKey is the log field key for a duration in seconds. DurationSecKey = "duration_sec" - // DurationMinKey - duration in minutes + // DurationMinKey is the log field key for a duration in minutes. DurationMinKey = "duration_min" - // DurationHourKey - duration in hours + // DurationHourKey is the log field key for a duration in hours. DurationHourKey = "duration_hour" ) @@ -37,7 +37,9 @@ func FDurationHour(duration time.Duration) zap.Field { return zap.Float64(DurationHourKey, duration.Hours()) } -// FDurationFn returns a function that returns a zap.Field with a given time.Duration converted to milliseconds under the key "duration". +// FDurationFn starts a timer and returns a function that creates a zap.Field +// with the time elapsed since the call to FDurationFn in milliseconds under the +// key "duration". func FDurationFn() func() zap.Field { start := time.Now() diff --git a/log/fields_error.go b/log/fields_error.go index e3fd0af4..b625c5db 100644 --- a/log/fields_error.go +++ b/log/fields_error.go @@ -6,20 +6,27 @@ import ( "go.uber.org/zap" ) +// Log field keys for errors. const ( ErrorTypeKey = "error_type" ErrorMessageKey = "error_message" ErrorStacktraceKey = "error_stacktrace" ) +// FError creates a zap.Field with err under the key "error_message". A nil err +// yields a no-op field. func FError(err error) zap.Field { return zap.NamedError(ErrorMessageKey, err) } +// FErrorType creates a zap.Field with the dynamic type name of err under the +// key "error_type". It panics if err is nil. func FErrorType(err error) zap.Field { return zap.String(ErrorTypeKey, reflect.TypeOf(err).String()) } +// FStackSkip creates a zap.Field with the current stack trace under the key +// "error_stacktrace", skipping the given number of frames. func FStackSkip(skip int) zap.Field { return zap.StackSkip(ErrorStacktraceKey, skip) } diff --git a/log/fields_http.go b/log/fields_http.go index c92f8528..0c1605fd 100644 --- a/log/fields_http.go +++ b/log/fields_http.go @@ -5,109 +5,169 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // HTTPServerNameKey is the log field key "http_server_name". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPServerNameKey = "http_server_name" - // Deprecated: use semconv messaging attributes instead. + // HTTPMethodKey is the log field key "http_method". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPMethodKey = "http_method" - // Deprecated: use semconv messaging attributes instead. + // HTTPTargetKey is the log field key "http_target". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPTargetKey = "http_target" - // Deprecated: use semconv messaging attributes instead. + // HTTPHostKey is the log field key "http_host". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPHostKey = "http_host" - // Deprecated: use semconv messaging attributes instead. + // HTTPStatusCodeKey is the log field key "http_status_code". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPStatusCodeKey = "http_status_code" - // Deprecated: use semconv messaging attributes instead. + // HTTPUserAgentKey is the log field key "http_user_agent". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPUserAgentKey = "http_user_agent" - // Deprecated: use semconv messaging attributes instead. + // HTTPClientIPKey is the log field key "http_client_ip". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPClientIPKey = "http_client_ip" - // Deprecated: use semconv messaging attributes instead. + // HTTPRequestContentLengthKey is the log field key "http_read_bytes". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPRequestContentLengthKey = "http_read_bytes" - // Deprecated: use semconv messaging attributes instead. + // HTTPWroteBytesKey is the log field key "http_wrote_bytes". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPWroteBytesKey = "http_wrote_bytes" // #nosec - // Deprecated: use semconv messaging attributes instead. + // HTTPSchemeKey is the log field key "http_scheme". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPSchemeKey = "http_scheme" - // Deprecated: use semconv messaging attributes instead. + // HTTPFlavorKey is the log field key "http_flavor". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPFlavorKey = "http_flavor" - // Deprecated: use semconv messaging attributes instead. + // HTTPRequestIDKey is the log field key "http_request_id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPRequestIDKey = "http_request_id" - // Deprecated: use semconv messaging attributes instead. + // HTTPSessionIDKey is the log field key "http_session_id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPSessionIDKey = "http_session_id" - // Deprecated: use semconv messaging attributes instead. + // HTTPTrackingIDKey is the log field key "http_tracking_id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPTrackingIDKey = "http_tracking_id" - // Deprecated: use semconv messaging attributes instead. + // HTTPRefererKey is the log field key "http_referer". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. HTTPRefererKey = "http_referer" ) -// Deprecated: use semconv messaging attributes instead. +// FHTTPServerName returns a field with the given value under [HTTPServerNameKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPServerName(id string) zap.Field { return zap.String(HTTPServerNameKey, id) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPRequestID returns a field with the given value under [HTTPRequestIDKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPRequestID(id string) zap.Field { return zap.String(HTTPRequestIDKey, id) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPSessionID returns a field with the given value under [HTTPSessionIDKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPSessionID(id string) zap.Field { return zap.String(HTTPSessionIDKey, id) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPTrackingID returns a field with the given value under [HTTPTrackingIDKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPTrackingID(id string) zap.Field { return zap.String(HTTPTrackingIDKey, id) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPRequestContentLength returns a field with the given value under [HTTPRequestContentLengthKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPRequestContentLength(bytes int64) zap.Field { return zap.Int64(HTTPRequestContentLengthKey, bytes) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPWroteBytes returns a field with the given value under [HTTPWroteBytesKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPWroteBytes(bytes int64) zap.Field { return zap.Int64(HTTPWroteBytesKey, bytes) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPStatusCode returns a field with the given value under [HTTPStatusCodeKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPStatusCode(status int) zap.Field { return zap.Int(HTTPStatusCodeKey, status) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPTarget returns a field with the given value under [HTTPTargetKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPTarget(target string) zap.Field { return zap.String(HTTPTargetKey, target) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPClientIP returns a field with the given value under [HTTPClientIPKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPClientIP(clientIP string) zap.Field { return zap.String(HTTPClientIPKey, clientIP) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPFlavor returns a field with the given value under [HTTPFlavorKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPFlavor(flavor string) zap.Field { return zap.String(HTTPFlavorKey, flavor) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPScheme returns a field with the given value under [HTTPSchemeKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPScheme(scheme string) zap.Field { return zap.String(HTTPSchemeKey, scheme) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPUserAgent returns a field with the given value under [HTTPUserAgentKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPUserAgent(userAgent string) zap.Field { return zap.String(HTTPUserAgentKey, userAgent) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPReferer returns a field with the given value under [HTTPRefererKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPReferer(host string) zap.Field { return zap.String(HTTPRefererKey, host) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPHost returns a field with the given value under [HTTPHostKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPHost(host string) zap.Field { return zap.String(HTTPHostKey, host) } -// Deprecated: use semconv messaging attributes instead. +// FHTTPMethod returns a field with the given value under [HTTPMethodKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FHTTPMethod(name string) zap.Field { return zap.String(HTTPMethodKey, name) } diff --git a/log/fields_messaging.go b/log/fields_messaging.go index 5bd950e5..59049267 100644 --- a/log/fields_messaging.go +++ b/log/fields_messaging.go @@ -6,84 +6,130 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // MessagingSystemKey is the log field key "messaging_system". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingSystemKey = "messaging_system" - // Deprecated: use semconv messaging attributes instead. + // MessagingDestinationKey is the log field key "messaging_destination". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingDestinationKey = "messaging_destination" - // Deprecated: use semconv messaging attributes instead. + // MessagingDestinationKindKey is the log field key "messaging_destination_kind". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingDestinationKindKey = "messaging_destination_kind" - // Deprecated: use semconv messaging attributes instead. + // MessagingProtocolKey is the log field key "messaging_protocol". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingProtocolKey = "messaging_protocol" - // Deprecated: use semconv messaging attributes instead. + // MessagingProtocolVersionKey is the log field key "messaging_protocol_version". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingProtocolVersionKey = "messaging_protocol_version" - // Deprecated: use semconv messaging attributes instead. + // MessagingURLKey is the log field key "messaging_url". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingURLKey = "messaging_url" - // Deprecated: use semconv messaging attributes instead. + // MessagingMessageIDKey is the log field key "messaging_message_id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingMessageIDKey = "messaging_message_id" - // Deprecated: use semconv messaging attributes instead. + // MessagingConversationIDKey is the log field key "messaging_conversation_id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingConversationIDKey = "messaging_conversation_id" - // Deprecated: use semconv messaging attributes instead. + // MessagingMessagePayloadSizeBytesKey is the log field key "messaging_message_payload_size_bytes". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingMessagePayloadSizeBytesKey = "messaging_message_payload_size_bytes" - // Deprecated: use semconv messaging attributes instead. + // MessagingMessagePayloadCompressedSizeBytesKey is the log field key "messaging_message_payload_compressed_size_bytes". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingMessagePayloadCompressedSizeBytesKey = "messaging_message_payload_compressed_size_bytes" ) -// Deprecated: use semconv messaging attributes instead. +// MessagingDestinationKind is the kind of a messaging destination. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. type MessagingDestinationKind string const ( - // Deprecated: use semconv messaging attributes instead. + // MessagingDestinationKindQueue is the queue destination kind. + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingDestinationKindQueue MessagingDestinationKind = "queue" - // Deprecated: use semconv messaging attributes instead. + // MessagingDestinationKindTopic is the topic destination kind. + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. MessagingDestinationKindTopic MessagingDestinationKind = "topic" ) -// Deprecated: use semconv messaging attributes instead. +// FMessagingSystem returns a field with the given value under [MessagingSystemKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingSystem(value string) zap.Field { return zap.String(MessagingSystemKey, value) } -// Deprecated: use semconv.MessagingDestinationName instead. +// FMessagingDestination returns the semconv.MessagingDestinationName attribute as a field. +// +// Deprecated: Use semconv.MessagingDestinationName with [Attribute] instead. func FMessagingDestination(value string) zap.Field { return Attribute(semconv.MessagingDestinationName(value)) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingDestinationKind returns a field with the given value under [MessagingDestinationKindKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingDestinationKind(value MessagingDestinationKind) zap.Field { return zap.String(MessagingDestinationKindKey, string(value)) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingProtocol returns a field with the given value under [MessagingProtocolKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingProtocol(value string) zap.Field { return zap.String(MessagingProtocolKey, value) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingProtocolVersion returns a field with the given value under [MessagingProtocolVersionKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingProtocolVersion(value string) zap.Field { return zap.String(MessagingProtocolVersionKey, value) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingURL returns a field with the given value under [MessagingURLKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingURL(value string) zap.Field { return zap.String(MessagingURLKey, value) } -// Deprecated: use semconv.MessagingMessageID instead. +// FMessagingMessageID returns the semconv.MessagingMessageID attribute as a field. +// +// Deprecated: Use semconv.MessagingMessageID with [Attribute] instead. func FMessagingMessageID(value string) zap.Field { return Attribute(semconv.MessagingMessageID(value)) } -// Deprecated: use semconv.MessagingMessageConversationID instead. +// FMessagingConversationID returns the semconv.MessagingMessageConversationID attribute as a field. +// +// Deprecated: Use semconv.MessagingMessageConversationID with [Attribute] instead. func FMessagingConversationID(value string) zap.Field { return Attribute(semconv.MessagingMessageConversationID(value)) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingMessagePayloadSizeBytes returns a field with the given value under [MessagingMessagePayloadSizeBytesKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingMessagePayloadSizeBytes(value string) zap.Field { return zap.String(MessagingMessagePayloadSizeBytesKey, value) } -// Deprecated: use semconv messaging attributes instead. +// FMessagingMessagePayloadCompressedSizeBytes returns a field with the given value under [MessagingMessagePayloadCompressedSizeBytesKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FMessagingMessagePayloadCompressedSizeBytes(value string) zap.Field { return zap.String(MessagingMessagePayloadCompressedSizeBytesKey, value) } diff --git a/log/fields_net.go b/log/fields_net.go index f4642319..8651b211 100644 --- a/log/fields_net.go +++ b/log/fields_net.go @@ -6,18 +6,26 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // NetHostIPKey is the log field key "net_host_ip". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. NetHostIPKey = "net_host_ip" - // Deprecated: use semconv messaging attributes instead. + // NetHostPortKey is the log field key "net_host_port". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. NetHostPortKey = "net_host_port" ) -// Deprecated: use semconv messaging attributes instead. +// FNetHostIP returns the semconv.HostIP attribute as a field. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FNetHostIP(ip string) zap.Field { return Attribute(semconv.HostIP(ip)) } -// Deprecated: use semconv messaging attributes instead. +// FNetHostPort returns a field with the given value under [NetHostPortKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FNetHostPort(port string) zap.Field { return zap.String(NetHostPortKey, port) } diff --git a/log/fields_service.go b/log/fields_service.go index 32b423f6..a50a9c76 100644 --- a/log/fields_service.go +++ b/log/fields_service.go @@ -5,53 +5,81 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // PeerServiceKey is the log field key "peer_service". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. PeerServiceKey = "peer_service" - // Deprecated: use semconv messaging attributes instead. + // ServiceTypeKey is the log field key "service_type". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceTypeKey = "service_type" - // Deprecated: use semconv messaging attributes instead. + // ServiceNameKey is the log field key "service_name". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceNameKey = "service_name" - // Deprecated: use semconv messaging attributes instead. + // ServiceMethodKey is the log field key "service_method". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceMethodKey = "service_method" - // Deprecated: use semconv messaging attributes instead. + // ServiceNamespaceKey is the log field key "service_namespace". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceNamespaceKey = "service_namespace" - // Deprecated: use semconv messaging attributes instead. + // ServiceInstanceIDKey is the log field key "service_instance.id". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceInstanceIDKey = "service_instance.id" - // Deprecated: use semconv messaging attributes instead. + // ServiceVersionKey is the log field key "service_version". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. ServiceVersionKey = "service_version" ) -// Deprecated: use semconv messaging attributes instead. +// FPeerService returns a field with the given value under [PeerServiceKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FPeerService(name string) zap.Field { return zap.String(PeerServiceKey, name) } -// Deprecated: use semconv messaging attributes instead. +// FServiceType returns a field with the given value under [ServiceTypeKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceType(name string) zap.Field { return zap.String(ServiceTypeKey, name) } -// Deprecated: use semconv messaging attributes instead. +// FServiceName returns a field with the given value under [ServiceNameKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceName(name string) zap.Field { return zap.String(ServiceNameKey, name) } -// Deprecated: use semconv messaging attributes instead. +// FServiceNamespace returns a field with the given value under [ServiceNamespaceKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceNamespace(namespace string) zap.Field { return zap.String(ServiceNamespaceKey, namespace) } -// Deprecated: use semconv messaging attributes instead. +// FServiceInstanceID returns a field with the given value under [ServiceInstanceIDKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceInstanceID(id string) zap.Field { return zap.String(ServiceInstanceIDKey, id) } -// Deprecated: use semconv messaging attributes instead. +// FServiceVersion returns a field with the given value under [ServiceVersionKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceVersion(version string) zap.Field { return zap.String(ServiceVersionKey, version) } -// Deprecated: use semconv messaging attributes instead. +// FServiceMethod returns a field with the given value under [ServiceMethodKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FServiceMethod(method string) zap.Field { return zap.String(ServiceMethodKey, method) } diff --git a/log/fields_stream.go b/log/fields_stream.go index f7ad4255..cc79b370 100644 --- a/log/fields_stream.go +++ b/log/fields_stream.go @@ -5,18 +5,26 @@ import ( ) const ( - // Deprecated: use semconv messaging attributes instead. + // StreamQueueKey is the log field key "queue". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. StreamQueueKey = "queue" - // Deprecated: use semconv messaging attributes instead. + // StreamSubjectKey is the log field key "subject". + // + // Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. StreamSubjectKey = "subject" ) -// Deprecated: use semconv messaging attributes instead. +// FStreamQueue returns a field with the given value under [StreamQueueKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FStreamQueue(queue string) zap.Field { return zap.String(StreamQueueKey, queue) } -// Deprecated: use semconv messaging attributes instead. +// FStreamSubject returns a field with the given value under [StreamSubjectKey]. +// +// Deprecated: Use OpenTelemetry semconv attributes with [Attribute] instead. func FStreamSubject(name string) zap.Field { return zap.String(StreamSubjectKey, name) } diff --git a/log/fields_trace.go b/log/fields_trace.go index 3c527af2..7a7c8c58 100644 --- a/log/fields_trace.go +++ b/log/fields_trace.go @@ -4,15 +4,18 @@ import ( "go.uber.org/zap" ) +// Log field keys for trace correlation. const ( SpanID = "span_id" TraceID = "trace_id" ) +// FSpanID creates a zap.Field with the given span id under the key "span_id". func FSpanID(traceID string) zap.Field { return zap.String(SpanID, traceID) } +// FTraceID creates a zap.Field with the given trace id under the key "trace_id". func FTraceID(traceID string) zap.Field { return zap.String(TraceID, traceID) } diff --git a/log/labeler.go b/log/labeler.go index 5e8795f9..0803f060 100644 --- a/log/labeler.go +++ b/log/labeler.go @@ -7,14 +7,18 @@ import ( "go.uber.org/zap" ) +// LabelerContextKey is the type of context keys under which a [Labeler] is +// stored. type LabelerContextKey string +// Labeler collects log fields that are added while a request is processed. +// It is safe for concurrent use. The zero value is ready to use. type Labeler struct { mu sync.Mutex fields []zap.Field } -// Add attributes to a Labeler. +// Add appends fields to the Labeler. func (l *Labeler) Add(fields ...zap.Field) { l.mu.Lock() defer l.mu.Unlock() @@ -22,7 +26,7 @@ func (l *Labeler) Add(fields ...zap.Field) { l.fields = append(l.fields, fields...) } -// Get returns a copy of the attributes added to the Labeler. +// Get returns a copy of the fields added to the Labeler. func (l *Labeler) Get() []zap.Field { l.mu.Lock() defer l.mu.Unlock() @@ -33,11 +37,15 @@ func (l *Labeler) Get() []zap.Field { return ret } +// InjectLabeler returns a copy of ctx carrying a new [Labeler] under key, +// together with that Labeler. func InjectLabeler(ctx context.Context, key LabelerContextKey) (context.Context, *Labeler) { l := &Labeler{} return context.WithValue(ctx, key, l), l } +// LabelerFromContext returns the [Labeler] stored in ctx under key and +// reports whether one was found. func LabelerFromContext(ctx context.Context, key LabelerContextKey) (*Labeler, bool) { if l, ok := ctx.Value(key).(*Labeler); ok { return l, true diff --git a/log/logger.go b/log/logger.go index 9cdca832..51dc0ac1 100644 --- a/log/logger.go +++ b/log/logger.go @@ -8,11 +8,15 @@ import ( "github.com/foomo/keel/env" ) +// config is the zap configuration of the last logger built by [NewLogger]; +// atomicLevel is the level shared by all loggers built from it. var ( config zap.Config atomicLevel = zap.NewAtomicLevelAt(zap.InfoLevel) ) +// init installs a logger configured from LOG_LEVEL (default "info") and +// LOG_FORMAT (default "json") as the zap global logger. func init() { zap.ReplaceGlobals(NewLogger( env.Get("LOG_LEVEL", "info"), @@ -20,7 +24,13 @@ func init() { )) } -// NewLogger return a new logger instance +// NewLogger builds a production zap logger with the given level (e.g. "info") +// and encoding ("json" or "console") and stores its configuration as the +// package configuration. It sets the shared [AtomicLevel] to level. Caller +// and stack traces are disabled unless level enables debug, overridable via +// LOG_DISABLE_CALLER and LOG_DISABLE_STACKTRACE. It panics if level is +// invalid or the logger cannot be built. The result is not installed as the +// global logger. func NewLogger(level, encoding string) *zap.Logger { config = zap.NewProductionConfig() @@ -54,27 +64,30 @@ func NewLogger(level, encoding string) *zap.Logger { return logger } -// Logger return the logger instance +// Logger returns the zap global logger. func Logger() *zap.Logger { return zap.L() } -// AtomicLevel return the configured atomic level +// AtomicLevel returns the level shared by loggers built with [NewLogger]. +// Changing it adjusts their level at runtime. func AtomicLevel() zap.AtomicLevel { return atomicLevel } -// IsDisableCaller returns the configured disabled caller value +// IsDisableCaller reports whether caller annotation is disabled. func IsDisableCaller() bool { return config.DisableCaller } -// IsDisableStacktrace returns the configured disabled stacktrace value +// IsDisableStacktrace reports whether automatic stack traces are disabled. func IsDisableStacktrace() bool { return config.DisableStacktrace } -// SetDisableCaller sets the given value and re-configures the logger +// SetDisableCaller sets whether caller annotation is disabled and, if the +// value changed, rebuilds and installs the zap global logger. It returns an +// error if the logger cannot be built. func SetDisableCaller(value bool) error { if value == config.DisableCaller { return nil @@ -92,7 +105,9 @@ func SetDisableCaller(value bool) error { return nil } -// SetDisableStacktrace sets the given value and re-configures the logger +// SetDisableStacktrace sets whether automatic stack traces are disabled and, +// if the value changed, rebuilds and installs the zap global logger. It +// returns an error if the logger cannot be built. func SetDisableStacktrace(value bool) error { if value == config.DisableStacktrace { return nil diff --git a/log/must.go b/log/must.go index 799e8cae..d0327646 100644 --- a/log/must.go +++ b/log/must.go @@ -6,7 +6,9 @@ import ( "go.uber.org/zap" ) -// Must logs a fatal error if given +// Must logs err at fatal level and exits the process if err is not nil. The +// optional msgAndArgs are a format string followed by its arguments and +// default to "Must". A nil l selects [Logger]. func Must(l *zap.Logger, err error, msgAndArgs ...any) { if err != nil { if l == nil { diff --git a/log/otel.go b/log/otel.go index 3720ece8..338a4b6a 100644 --- a/log/otel.go +++ b/log/otel.go @@ -7,6 +7,8 @@ import ( "go.uber.org/zap" ) +// Attributes converts the valid attributes in attrs to zap fields using +// [Attribute]. func Attributes(attrs ...attribute.KeyValue) []zap.Field { ret := make([]zap.Field, 0, len(attrs)) for _, attr := range attrs { @@ -18,6 +20,9 @@ func Attributes(attrs ...attribute.KeyValue) []zap.Field { return ret } +// Attribute converts attr to a zap field of the matching type, with its key +// transformed by [AttributeKey]. A string slice with a single element is +// logged as a plain string. An invalid attr yields a skipped field. func Attribute(attr attribute.KeyValue) zap.Field { if !attr.Valid() { return zap.Skip() @@ -49,6 +54,7 @@ func Attribute(attr attribute.KeyValue) zap.Field { } } +// AttributeKey returns key with every "." replaced by "_". func AttributeKey(key attribute.Key) string { return strings.ReplaceAll(string(key), ".", "_") } diff --git a/log/with.go b/log/with.go index b2fb6706..e4315588 100644 --- a/log/with.go +++ b/log/with.go @@ -16,6 +16,7 @@ import ( keelhttpcontext "github.com/foomo/keel/net/http/context" ) +// With returns a child of l with fields added. A nil l selects [Logger]. func With(l *zap.Logger, fields ...zap.Field) *zap.Logger { if l == nil { l = Logger() @@ -24,6 +25,8 @@ func With(l *zap.Logger, fields ...zap.Field) *zap.Logger { return l.With(fields...) } +// WithAttributes returns a child of l with attrs added as fields, with "." +// in keys replaced by "_". func WithAttributes(l *zap.Logger, attrs ...attribute.KeyValue) *zap.Logger { if l == nil { l = Logger() @@ -37,14 +40,19 @@ func WithAttributes(l *zap.Logger, attrs ...attribute.KeyValue) *zap.Logger { return l.With(fields...) } +// WithError returns a child of l with the error type and message of err. +// It panics if err is nil. func WithError(l *zap.Logger, err error) *zap.Logger { return With(l, FErrorType(err), FError(err)) } +// WithServiceName returns a child of l with the semconv service name. func WithServiceName(l *zap.Logger, name string) *zap.Logger { return With(l, Attribute(semconv.ServiceName(name))) } +// WithTraceID returns a child of l with the trace and span ids of the span +// in ctx, or l unchanged if that span is invalid or not sampled. func WithTraceID(l *zap.Logger, ctx context.Context) *zap.Logger { if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() && spanCtx.IsSampled() { l = With(l, FTraceID(spanCtx.TraceID().String()), FSpanID(spanCtx.SpanID().String())) @@ -53,14 +61,19 @@ func WithTraceID(l *zap.Logger, ctx context.Context) *zap.Logger { return l } +// WithHTTPServerName returns a child of l with name under [HTTPServerNameKey]. func WithHTTPServerName(l *zap.Logger, name string) *zap.Logger { return With(l, FHTTPServerName(name)) } +// WithHTTPFlavor returns a child of l with the network protocol name "HTTP" +// and the protocol version of r. func WithHTTPFlavor(l *zap.Logger, r *http.Request) *zap.Logger { return With(l, Attributes(semconv.NetworkProtocolName("HTTP"), semconv.NetworkProtocolVersion(fmt.Sprintf("%d.%d", r.ProtoMajor, r.ProtoMinor)))...) } +// WithHTTPScheme returns a child of l with the URL scheme of r, "https" if r +// was received over TLS and "http" otherwise. func WithHTTPScheme(l *zap.Logger, r *http.Request) *zap.Logger { if r.TLS != nil { return With(l, Attribute(semconv.URLScheme("https"))) @@ -69,6 +82,8 @@ func WithHTTPScheme(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPSessionID returns a child of l with the session id from the +// X-Session-Id header or the request context, or l unchanged if none is set. func WithHTTPSessionID(l *zap.Logger, r *http.Request) *zap.Logger { if id := r.Header.Get("X-Session-Id"); id != "" { return With(l, Attribute(semconv.SessionID(id))) @@ -79,6 +94,8 @@ func WithHTTPSessionID(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPRequestID returns a child of l with the request id from the +// X-Request-ID header or the request context, or l unchanged if none is set. func WithHTTPRequestID(l *zap.Logger, r *http.Request) *zap.Logger { if id := r.Header.Get("X-Request-ID"); id != "" { return With(l, Attribute(foomosemconv.HTTPXRequestID(id))) @@ -89,6 +106,8 @@ func WithHTTPRequestID(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPReferer returns a child of l with the referer from the X-Referer +// or Referer header, or l unchanged if none is set. func WithHTTPReferer(l *zap.Logger, r *http.Request) *zap.Logger { if value := r.Header.Get("X-Referer"); value != "" { return With(l, Attribute(foomosemconv.HTTPXRequestReferer(value))) @@ -99,6 +118,9 @@ func WithHTTPReferer(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPHost returns a child of l with the host name from the +// X-Forwarded-Host header, falling back to r.Host, or the URL host for +// absolute request URLs. func WithHTTPHost(l *zap.Logger, r *http.Request) *zap.Logger { if value := r.Header.Get("X-Forwarded-Host"); value != "" { return With(l, Attribute(semconv.HostName(value))) @@ -109,6 +131,8 @@ func WithHTTPHost(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPTrackingID returns a child of l with the tracking id from the +// X-Tracking-Id header or the request context, or l unchanged if none is set. func WithHTTPTrackingID(l *zap.Logger, r *http.Request) *zap.Logger { if id := r.Header.Get("X-Tracking-Id"); id != "" { return With(l, Attribute(foomosemconv.TrackingID(id))) @@ -119,6 +143,8 @@ func WithHTTPTrackingID(l *zap.Logger, r *http.Request) *zap.Logger { } } +// WithHTTPClientIP returns a child of l with the client address taken from +// the first X-Forwarded-For entry, X-Real-IP or r.RemoteAddr, in that order. func WithHTTPClientIP(l *zap.Logger, r *http.Request) *zap.Logger { var clientIP string @@ -143,6 +169,10 @@ func WithHTTPClientIP(l *zap.Logger, r *http.Request) *zap.Logger { return l } +// WithHTTPRequest returns a child of l with the fields describing the +// incoming request r: host, referer, request, session and tracking ids, +// scheme, protocol, client address, trace ids, path, user agent, method and +// size. func WithHTTPRequest(l *zap.Logger, r *http.Request) *zap.Logger { l = WithHTTPHost(l, r) l = WithHTTPReferer(l, r) @@ -162,6 +192,8 @@ func WithHTTPRequest(l *zap.Logger, r *http.Request) *zap.Logger { )...) } +// WithHTTPRequestOut is like [WithHTTPRequest] for an outgoing request r, +// omitting referer and client address. func WithHTTPRequestOut(l *zap.Logger, r *http.Request) *zap.Logger { l = WithHTTPHost(l, r) l = WithHTTPRequestID(l, r) diff --git a/markdown/doc.go b/markdown/doc.go new file mode 100644 index 00000000..9293eb0d --- /dev/null +++ b/markdown/doc.go @@ -0,0 +1,3 @@ +// Package markdown provides a small builder for markdown text, including +// tables, and helpers for formatting values as markdown. +package markdown diff --git a/markdown/markdown.go b/markdown/markdown.go index 936ea545..986f65a4 100644 --- a/markdown/markdown.go +++ b/markdown/markdown.go @@ -6,27 +6,36 @@ import ( markdowntable "github.com/fbiville/markdown-table-formatter/pkg/markdown" ) -// Markdown output helper +// Markdown accumulates markdown text in memory. The zero value is an empty +// document ready to use. It is not safe for concurrent use. type Markdown struct { value string } +// Println appends the operands formatted as by [fmt.Sprintln]. func (s *Markdown) Println(a ...any) { s.value += fmt.Sprintln(a...) } +// Printf appends the operands formatted as by [fmt.Sprintf], followed by a +// newline. func (s *Markdown) Printf(format string, a ...any) { s.Println(fmt.Sprintf(format, a...)) } +// Print appends the operands formatted as by [fmt.Sprint]. func (s *Markdown) Print(a ...any) { s.value += fmt.Sprint(a...) } +// String returns the accumulated markdown. func (s *Markdown) String() string { return s.value } +// Table appends a pretty-printed markdown table with the given headers and +// rows, sorted alphabetically in ascending order. It panics if the table +// cannot be formatted. func (s *Markdown) Table(headers []string, rows [][]string) { table, err := markdowntable.NewTableFormatterBuilder(). WithAlphabeticalSortIn(markdowntable.ASCENDING_ORDER). diff --git a/markdown/utils.go b/markdown/utils.go index 5f26d50a..cd22fcc0 100644 --- a/markdown/utils.go +++ b/markdown/utils.go @@ -4,6 +4,7 @@ import ( "fmt" ) +// Code wraps v in backticks as inline code, or returns "" if v is empty. func Code(v string) string { if v == "" { return "" @@ -12,6 +13,7 @@ func Code(v string) string { return "`" + v + "`" } +// Name returns the result of v.Name() if v has such a method, or "". func Name(v any) string { if i, ok := v.(interface { Name() string @@ -22,6 +24,7 @@ func Name(v any) string { return "" } +// String returns v.String() if v implements [fmt.Stringer], or "". func String(v any) string { if i, ok := v.(fmt.Stringer); ok { return i.String() diff --git a/metrics/doc.go b/metrics/doc.go new file mode 100644 index 00000000..ac0a32ce --- /dev/null +++ b/metrics/doc.go @@ -0,0 +1,6 @@ +// Package metrics provides constructors for Prometheus request metrics +// registered with the default registry via promauto. +// +// All constructors are deprecated in favor of OpenTelemetry instruments +// created from [github.com/foomo/keel/telemetry.Meter]. +package metrics diff --git a/metrics/metrics.go b/metrics/metrics.go index 473309f7..860a3e46 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -5,7 +5,11 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) -// Deprecated: NewRequestSizeSummaryVec use telemetry.Meter(...) +// NewRequestSizeSummaryVec registers and returns a summary vector named +// __request_size_bytes that tracks request sizes, +// partitioned by labelNames. It panics if the metric is already registered. +// +// Deprecated: Use [github.com/foomo/keel/telemetry.Meter] instead. func NewRequestSizeSummaryVec(namespace, subsystem string, labelNames []string) *prometheus.SummaryVec { return promauto.NewSummaryVec( prometheus.SummaryOpts{ @@ -18,7 +22,11 @@ func NewRequestSizeSummaryVec(namespace, subsystem string, labelNames []string) ) } -// Deprecated: NewResponseSizeSummaryVec use telemetry.Meter(...) +// NewResponseSizeSummaryVec registers and returns a summary vector named +// __response_size_bytes that tracks response sizes, +// partitioned by labelNames. It panics if the metric is already registered. +// +// Deprecated: Use [github.com/foomo/keel/telemetry.Meter] instead. func NewResponseSizeSummaryVec(namespace, subsystem string, labelNames []string) *prometheus.SummaryVec { return promauto.NewSummaryVec( prometheus.SummaryOpts{ @@ -31,7 +39,11 @@ func NewResponseSizeSummaryVec(namespace, subsystem string, labelNames []string) ) } -// Deprecated: NewRequestsCounterVec use telemetry.Meter(...) +// NewRequestsCounterVec registers and returns a counter vector named +// __requests_total that counts requests, partitioned +// by labelNames. It panics if the metric is already registered. +// +// Deprecated: Use [github.com/foomo/keel/telemetry.Meter] instead. func NewRequestsCounterVec(namespace, subsystem string, labelNames []string) *prometheus.CounterVec { return promauto.NewCounterVec( prometheus.CounterOpts{ @@ -44,7 +56,11 @@ func NewRequestsCounterVec(namespace, subsystem string, labelNames []string) *pr ) } -// Deprecated: NewRequestDurationHistogram use telemetry.Meter(...) +// NewRequestDurationHistogram registers and returns a histogram named +// __request_duration_seconds with 50 exponential +// buckets starting at 0.1ms. It panics if the metric is already registered. +// +// Deprecated: Use [github.com/foomo/keel/telemetry.Meter] instead. func NewRequestDurationHistogram(namespace, subsystem string) prometheus.Histogram { return promauto.NewHistogram(prometheus.HistogramOpts{ Namespace: namespace, diff --git a/metrics/metrics_http.go b/metrics/metrics_http.go index 85e0f740..1b32d036 100644 --- a/metrics/metrics_http.go +++ b/metrics/metrics_http.go @@ -4,22 +4,34 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// Deprecated: use otelhttp +// NewHTTPRequestSizeSummaryVec returns [NewRequestSizeSummaryVec] with +// subsystem "http" and labels "method" and "code". +// +// Deprecated: Use otelhttp instrumentation instead. func NewHTTPRequestSizeSummaryVec(namespace string) *prometheus.SummaryVec { return NewRequestSizeSummaryVec(namespace, "http", []string{"method", "code"}) } -// Deprecated: use otelhttp +// NewHTTPResponseSizeSummaryVec returns [NewResponseSizeSummaryVec] with +// subsystem "http" and labels "method" and "code". +// +// Deprecated: Use otelhttp instrumentation instead. func NewHTTPResponseSizeSummaryVec(namespace string) *prometheus.SummaryVec { return NewResponseSizeSummaryVec(namespace, "http", []string{"method", "code"}) } -// Deprecated: use otelhttp +// NewHTTPRequestsCounterVec returns [NewRequestsCounterVec] with +// subsystem "http" and labels "method" and "code". +// +// Deprecated: Use otelhttp instrumentation instead. func NewHTTPRequestsCounterVec(namespace string) *prometheus.CounterVec { return NewRequestsCounterVec(namespace, "http", []string{"method", "code"}) } -// Deprecated: use otelhttp +// NewHTTPRequestDurationHistogram returns [NewRequestDurationHistogram] +// with subsystem "http". +// +// Deprecated: Use otelhttp instrumentation instead. func NewHTTPRequestDurationHistogram(namespace string) prometheus.Histogram { return NewRequestDurationHistogram(namespace, "http") } diff --git a/metrics/readme.go b/metrics/readme.go index 32527401..e8551ce9 100644 --- a/metrics/readme.go +++ b/metrics/readme.go @@ -5,7 +5,10 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// Deprecated: will be removed in upcoming releases +// Readme returns a markdown table of all metrics gathered from the default +// Prometheus gatherer, or an empty string if there are none. +// +// Deprecated: Readme has no replacement. func Readme() string { md := markdown.Markdown{} diff --git a/net/http/client.go b/net/http/client.go index 172c6635..f2630886 100644 --- a/net/http/client.go +++ b/net/http/client.go @@ -13,30 +13,45 @@ import ( "go.uber.org/zap" ) +// HTTPClientOption configures an [http.Client] created by [NewInternalHTTPClient], +// [NewExternalHTTPClient] or [NewHTTPClient]. Options are applied in order. +// +// Transport options (such as [HTTPClientWithMaxConnsPerHost]) modify the +// client's [*http.Transport] and panic if the transport has already been +// replaced by a wrapping option such as [HTTPClientWithTelemetry] or +// [HTTPClientWithRoundTripware], or by a non-[*http.Transport] set through +// [HTTPClientWithTransport]. Apply wrapping options last. type HTTPClientOption func(*http.Client) // ------------------------------------------------------------------ // Client options // ------------------------------------------------------------------ +// HTTPClientWithTimeout sets the client's overall request timeout +// ([http.Client.Timeout]). func HTTPClientWithTimeout(o time.Duration) HTTPClientOption { return func(v *http.Client) { v.Timeout = o } } +// HTTPClientWithJar sets the client's cookie jar. func HTTPClientWithJar(o http.CookieJar) HTTPClientOption { return func(v *http.Client) { v.Jar = o } } +// HTTPClientWithTransport replaces the client's transport. Transport options +// applied afterwards panic unless o is an [*http.Transport]. func HTTPClientWithTransport(o http.RoundTripper) HTTPClientOption { return func(v *http.Client) { v.Transport = o } } +// HTTPClientWithCheckRedirect sets the client's redirect policy +// ([http.Client.CheckRedirect]). func HTTPClientWithCheckRedirect(o func(req *http.Request, via []*http.Request) error) HTTPClientOption { return func(v *http.Client) { v.CheckRedirect = o @@ -44,7 +59,9 @@ func HTTPClientWithCheckRedirect(o func(req *http.Request, via []*http.Request) } // HTTPClientWithoutCrossHostRedirects stops the client following a redirect to a -// different host, and caps redirect depth at max. +// different host, and caps redirect depth at v. When either limit is hit the +// last response is returned with its body unclosed, as for +// [http.ErrUseLastResponse]. func HTTPClientWithoutCrossHostRedirects(v int) HTTPClientOption { return HTTPClientWithCheckRedirect(func(req *http.Request, via []*http.Request) error { if len(via) >= v || req.URL.Host != via[0].URL.Host { @@ -59,48 +76,62 @@ func HTTPClientWithoutCrossHostRedirects(v int) HTTPClientOption { // Transport options // ------------------------------------------------------------------ +// HTTPClientWithProxy sets the transport's proxy selection function. func HTTPClientWithProxy(o func(request *http.Request) (*url.URL, error)) HTTPClientOption { return httpTransportOption("HTTPClientWithProxy", func(t *http.Transport) { t.Proxy = o }) } +// HTTPClientWithDialContext sets the transport's dial function for +// unencrypted TCP connections. func HTTPClientWithDialContext(o func(ctx context.Context, network, addr string) (net.Conn, error)) HTTPClientOption { return httpTransportOption("HTTPClientWithDialContext", func(t *http.Transport) { t.DialContext = o }) } +// HTTPClientWithDialTLSContext sets the transport's dial function for TLS +// connections to non-proxied HTTPS requests. func HTTPClientWithDialTLSContext(o func(ctx context.Context, network, addr string) (net.Conn, error)) HTTPClientOption { return httpTransportOption("HTTPClientWithDialTLSContext", func(t *http.Transport) { t.DialTLSContext = o }) } +// HTTPClientWithTLSClientConfig sets the transport's TLS configuration. func HTTPClientWithTLSClientConfig(o *tls.Config) HTTPClientOption { return httpTransportOption("HTTPClientWithTLSClientConfig", func(t *http.Transport) { t.TLSClientConfig = o }) } +// HTTPClientWithTLSHandshakeTimeout sets the transport's TLS handshake +// timeout. func HTTPClientWithTLSHandshakeTimeout(o time.Duration) HTTPClientOption { return httpTransportOption("HTTPClientWithTLSHandshakeTimeout", func(t *http.Transport) { t.TLSHandshakeTimeout = o }) } +// HTTPClientWithDisableKeepAlives sets whether the transport disables HTTP +// keep-alives and uses each connection for a single request only. func HTTPClientWithDisableKeepAlives(o bool) HTTPClientOption { return httpTransportOption("HTTPClientWithDisableKeepAlives", func(t *http.Transport) { t.DisableKeepAlives = o }) } +// HTTPClientWithDisableCompression sets whether the transport refrains from +// requesting gzip-compressed responses. func HTTPClientWithDisableCompression(o bool) HTTPClientOption { return httpTransportOption("HTTPClientWithDisableCompression", func(t *http.Transport) { t.DisableCompression = o }) } +// HTTPClientWithMaxIdleConns caps pooled idle connections across all hosts. +// Zero means no limit. func HTTPClientWithMaxIdleConns(o int) HTTPClientOption { return httpTransportOption("HTTPClientWithMaxIdleConns", func(t *http.Transport) { t.MaxIdleConns = o @@ -149,24 +180,32 @@ func HTTPClientWithResponseHeaderTimeout(o time.Duration) HTTPClientOption { }) } +// HTTPClientWithExpectContinueTimeout sets how long the transport waits for +// a server's first response headers after sending "Expect: 100-continue". func HTTPClientWithExpectContinueTimeout(o time.Duration) HTTPClientOption { return httpTransportOption("HTTPClientWithExpectContinueTimeout", func(t *http.Transport) { t.ExpectContinueTimeout = o }) } +// HTTPClientWithTLSNextProto sets the transport's TLS ALPN protocol upgrade +// handlers ([http.Transport.TLSNextProto]). func HTTPClientWithTLSNextProto(o map[string]func(authority string, c *tls.Conn) http.RoundTripper) HTTPClientOption { return httpTransportOption("HTTPClientWithTLSNextProto", func(t *http.Transport) { t.TLSNextProto = o }) } +// HTTPClientWithProxyConnectHeader sets the headers sent to proxies in +// CONNECT requests. func HTTPClientWithProxyConnectHeader(o http.Header) HTTPClientOption { return httpTransportOption("HTTPClientWithProxyConnectHeader", func(t *http.Transport) { t.ProxyConnectHeader = o }) } +// HTTPClientWithGetProxyConnectHeader sets the function returning headers +// sent to a proxy in CONNECT requests. func HTTPClientWithGetProxyConnectHeader(o func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error)) HTTPClientOption { return httpTransportOption("HTTPClientWithGetProxyConnectHeader", func(t *http.Transport) { t.GetProxyConnectHeader = o @@ -181,12 +220,16 @@ func HTTPClientWithMaxResponseHeaderBytes(o int64) HTTPClientOption { }) } +// HTTPClientWithWriteBufferSize sets the size of the transport's write +// buffer in bytes. func HTTPClientWithWriteBufferSize(o int) HTTPClientOption { return httpTransportOption("HTTPClientWithWriteBufferSize", func(t *http.Transport) { t.WriteBufferSize = o }) } +// HTTPClientWithReadBufferSize sets the size of the transport's read buffer +// in bytes. func HTTPClientWithReadBufferSize(o int) HTTPClientOption { return httpTransportOption("HTTPClientWithReadBufferSize", func(t *http.Transport) { t.ReadBufferSize = o @@ -195,8 +238,11 @@ func HTTPClientWithReadBufferSize(o int) HTTPClientOption { // HTTPClientWithForceAttemptHTTP2 enables HTTP/2 negotiation. // -// Deprecated: use HTTPClientWithProtocols or HTTPClientWithHTTP1Only. Transport -// consults Protocols first, so this field is ignored when Protocols is set. +// The transport consults Protocols first, so this setting is ignored when +// Protocols is set, which is the case for [NewInternalHTTPClient] and +// [NewExternalHTTPClient]. +// +// Deprecated: Use [HTTPClientWithProtocols] instead. func HTTPClientWithForceAttemptHTTP2(o bool) HTTPClientOption { return httpTransportOption("HTTPClientWithForceAttemptHTTP2", func(t *http.Transport) { t.ForceAttemptHTTP2 = o @@ -214,7 +260,8 @@ func HTTPClientWithProtocols(o *http.Protocols) HTTPClientOption { // Wrapping options — apply these last // ------------------------------------------------------------------ -// HTTPClientWithRoundTripware wraps the transport. Apply after all +// HTTPClientWithRoundTripware wraps the transport with the given +// [roundtripware.RoundTripware], using l for logging. Apply after all // transport-tuning options. func HTTPClientWithRoundTripware(l *zap.Logger, roundTripware ...roundtripware.RoundTripware) HTTPClientOption { return func(v *http.Client) { @@ -237,16 +284,15 @@ func HTTPClientWithTelemetry(opts ...otelhttp.Option) HTTPClientOption { // Dialer and transport // ------------------------------------------------------------------ -// ------------------------------------------------------------------ -// Dialer and transport -// ------------------------------------------------------------------ - -// DefaultHTTPTransportDialer returns the original dialer, unchanged. +// DefaultHTTPTransportDialer returns the legacy dialer used by +// [DefaultHTTPTransport]. +// +// It uses a 45s dial timeout and keep-alive period. A 45s dial timeout is far +// too slack for in-cluster traffic, where a slow dial means the endpoint is +// gone rather than far away, and the loose keep-alive takes minutes to notice a +// pod that disappeared without a FIN. // -// Deprecated: use NewHTTPTransportDialer. A 45s dial timeout is far too slack for -// in-cluster traffic, where a slow dial means the endpoint is gone rather than -// far away, and the loose keep-alive takes minutes to notice a pod that -// disappeared without a FIN. +// Deprecated: Use [NewHTTPTransportDialer] instead. func DefaultHTTPTransportDialer() *net.Dialer { return &net.Dialer{ Timeout: 45 * time.Second, @@ -254,10 +300,11 @@ func DefaultHTTPTransportDialer() *net.Dialer { } } -// NewHTTPTransportDialer returns the dialer used by NewHTTPTransport. +// NewHTTPTransportDialer returns the dialer used by [NewHTTPTransport]. // -// internal selects tuning for pod-to-pod calls; false targets the public -// internet. +// internal selects tuning for pod-to-pod calls (2s dial timeout); false targets +// the public internet (5s dial timeout). Both enable aggressive TCP keep-alive +// probing (15s idle, 5s interval, 3 probes). func NewHTTPTransportDialer(internal bool) *net.Dialer { return &net.Dialer{ // In-cluster the peer is one network hop away, so a slow dial means the @@ -279,12 +326,13 @@ func NewHTTPTransportDialer(internal bool) *net.Dialer { } } -// DefaultHTTPTransport returns the original transport, unchanged. +// DefaultHTTPTransport returns the legacy transport used by [NewHTTPClient]. +// +// This transport disables connection pooling and leaves MaxIdleConnsPerHost +// (defaulting to 2), MaxConnsPerHost, IdleConnTimeout and +// ResponseHeaderTimeout unset. // -// Deprecated: use NewHTTPTransport. This transport disables connection pooling and -// leaves MaxIdleConnsPerHost (defaulting to 2), MaxConnsPerHost, -// IdleConnTimeout and ResponseHeaderTimeout unset. It is kept only so existing -// callers see no behaviour change. +// Deprecated: Use [NewHTTPTransport] instead. func DefaultHTTPTransport() *http.Transport { return &http.Transport{ Proxy: http.ProxyFromEnvironment, @@ -295,7 +343,8 @@ func DefaultHTTPTransport() *http.Transport { } } -// NewHTTPTransport returns a tuned transport. +// NewHTTPTransport returns a tuned transport. Each call returns a distinct +// [*http.Transport] and therefore a distinct connection pool. // // internal selects tuning for another Deployment in the same cluster, reached // through a ClusterIP Service with no sidecar or gateway in the path; false @@ -369,7 +418,9 @@ func NewHTTPTransport(internal bool) *http.Transport { // ------------------------------------------------------------------ // NewInternalHTTPClient returns a client tuned for pod-to-pod calls through a -// ClusterIP Service with no mesh or gateway in the path. +// ClusterIP Service with no mesh or gateway in the path, using +// [NewHTTPTransport] with internal set to true and a 10s timeout. Options are +// applied afterwards, so [HTTPClientWithTimeout] overrides the timeout. // // Create one client per logical upstream at process start and reuse it for the // lifetime of the process: the Transport is the connection pool, so a client @@ -378,18 +429,16 @@ func NewInternalHTTPClient(opts ...HTTPClientOption) *http.Client { return newHTTPClient(true, opts...) } -// NewExternalHTTPClient returns a client tuned for third-party HTTPS APIs. -// The same reuse rule applies as for NewInternalHTTPClient. +// NewExternalHTTPClient returns a client tuned for third-party HTTPS APIs, +// using [NewHTTPTransport] with internal set to false and a 30s timeout. +// The same reuse rule applies as for [NewInternalHTTPClient]. func NewExternalHTTPClient(opts ...HTTPClientOption) *http.Client { return newHTTPClient(false, opts...) } -// NewHTTPClient returns the original client, unchanged: the legacy transport -// from DefaultHTTPTransport and a two minute timeout. -// -// Deprecated: use NewInternalHTTPClient or NewExternalHTTPClient. This -// constructor is frozen so that existing callers see no behaviour change, which -// means it keeps the following problems: +// NewHTTPClient returns a client using the legacy transport from +// [DefaultHTTPTransport] and a two minute timeout. Compared to the tuned +// constructors it differs as follows: // // setting legacy NewExternalHTTPClient // DisableKeepAlives true false @@ -406,9 +455,11 @@ func NewExternalHTTPClient(opts ...HTTPClientOption) *http.Client { // intermittent dial failures that look like a network fault. // // Migrating is not purely mechanical. Callers relying on the two minute timeout -// for slow endpoints must also raise ResponseHeaderTimeout, which the new +// for slow endpoints must also raise ResponseHeaderTimeout, which the tuned // defaults bound at 10s — otherwise a request that takes 45s to produce its // first byte fails even with a generous overall timeout. +// +// Deprecated: Use [NewInternalHTTPClient] or [NewExternalHTTPClient] instead. func NewHTTPClient(opts ...HTTPClientOption) *http.Client { inst := &http.Client{ Transport: DefaultHTTPTransport(), @@ -446,9 +497,8 @@ func newHTTPClient(internal bool, opts ...HTTPClientOption) *http.Client { // Wrapping options such as HTTPClientWithTelemetry and // HTTPClientWithRoundTripware replace Transport with a decorator, after which // the underlying transport can no longer be reached by a type assertion. The -// original implementation silently skipped the setting in that case; panicking -// at construction time beats discovering an ignored connection-pool setting in -// production. +// returned option panics in that case rather than silently ignoring the +// setting, so a misordered option fails at construction time. func httpTransportOption(name string, f func(*http.Transport)) HTTPClientOption { return func(v *http.Client) { t, ok := v.Transport.(*http.Transport) @@ -460,6 +510,7 @@ func httpTransportOption(name string, f func(*http.Transport)) HTTPClientOption } } +// pick returns a if cond is true and b otherwise. func pick[T any](cond bool, a, b T) T { if cond { return a diff --git a/net/http/context/contextkey.go b/net/http/context/contextkey.go index c3bf2dbb..7b7eeb76 100644 --- a/net/http/context/contextkey.go +++ b/net/http/context/contextkey.go @@ -1,3 +1,5 @@ package context +// contextKey is the type of the context keys defined by this package; it +// prevents collisions with keys defined elsewhere. type contextKey string diff --git a/net/http/context/doc.go b/net/http/context/doc.go new file mode 100644 index 00000000..662f7db5 --- /dev/null +++ b/net/http/context/doc.go @@ -0,0 +1,9 @@ +// Package context provides helpers to store and retrieve request-scoped +// identifiers in a [context.Context]: the referer, request ID, session ID and +// tracking ID. +// +// Middleware in [github.com/foomo/keel/net/http/middleware] stores the request, +// session and tracking IDs of incoming requests, and round-trippers in +// [github.com/foomo/keel/net/http/roundtripware] forward these values on +// outgoing requests. +package context diff --git a/net/http/context/referrer.go b/net/http/context/referrer.go index 2c04a55a..301bd8d6 100644 --- a/net/http/context/referrer.go +++ b/net/http/context/referrer.go @@ -4,8 +4,11 @@ import ( "context" ) +// ContextKeyReferer is the context key under which the referer is stored. const ContextKeyReferer contextKey = "referer" +// GetReferer returns the referer stored in ctx by [SetReferer]. It reports false +// if no referer is present. func GetReferer(ctx context.Context) (string, bool) { if value, ok := ctx.Value(ContextKeyReferer).(string); ok { return value, true @@ -14,6 +17,7 @@ func GetReferer(ctx context.Context) (string, bool) { } } +// SetReferer returns a copy of ctx carrying referer as the referer. func SetReferer(ctx context.Context, referer string) context.Context { return context.WithValue(ctx, ContextKeyReferer, referer) } diff --git a/net/http/context/requestid.go b/net/http/context/requestid.go index 7b80a85e..b98f0334 100644 --- a/net/http/context/requestid.go +++ b/net/http/context/requestid.go @@ -4,8 +4,11 @@ import ( "context" ) +// ContextKeyRequestID is the context key under which the request ID is stored. const ContextKeyRequestID contextKey = "requestId" +// GetRequestID returns the request ID stored in ctx by [SetRequestID]. It reports false +// if no request ID is present. func GetRequestID(ctx context.Context) (string, bool) { if value, ok := ctx.Value(ContextKeyRequestID).(string); ok { return value, true @@ -14,6 +17,7 @@ func GetRequestID(ctx context.Context) (string, bool) { } } +// SetRequestID returns a copy of ctx carrying requestID as the request ID. func SetRequestID(ctx context.Context, requestID string) context.Context { return context.WithValue(ctx, ContextKeyRequestID, requestID) } diff --git a/net/http/context/sessionid.go b/net/http/context/sessionid.go index f713c638..7a758241 100644 --- a/net/http/context/sessionid.go +++ b/net/http/context/sessionid.go @@ -4,8 +4,11 @@ import ( "context" ) +// ContextKeySessionID is the context key under which the session ID is stored. const ContextKeySessionID contextKey = "sessionId" +// GetSessionID returns the session ID stored in ctx by [SetSessionID]. It reports false +// if no session ID is present. func GetSessionID(ctx context.Context) (string, bool) { if value, ok := ctx.Value(ContextKeySessionID).(string); ok { return value, true @@ -14,6 +17,7 @@ func GetSessionID(ctx context.Context) (string, bool) { } } +// SetSessionID returns a copy of ctx carrying sessionID as the session ID. func SetSessionID(ctx context.Context, sessionID string) context.Context { return context.WithValue(ctx, ContextKeySessionID, sessionID) } diff --git a/net/http/context/trackingid.go b/net/http/context/trackingid.go index 880ea5c0..8de06d48 100644 --- a/net/http/context/trackingid.go +++ b/net/http/context/trackingid.go @@ -4,8 +4,11 @@ import ( "context" ) +// ContextKeyTrackingID is the context key under which the tracking ID is stored. const ContextKeyTrackingID contextKey = "trackingId" +// GetTrackingID returns the tracking ID stored in ctx by [SetTrackingID]. It reports false +// if no tracking ID is present. func GetTrackingID(ctx context.Context) (string, bool) { if value, ok := ctx.Value(ContextKeyTrackingID).(string); ok { return value, true @@ -14,6 +17,7 @@ func GetTrackingID(ctx context.Context) (string, bool) { } } +// SetTrackingID returns a copy of ctx carrying trackingID as the tracking ID. func SetTrackingID(ctx context.Context, trackingID string) context.Context { return context.WithValue(ctx, ContextKeyTrackingID, trackingID) } diff --git a/net/http/cookie/cookie.go b/net/http/cookie/cookie.go index af6f085e..f6e5cf8f 100644 --- a/net/http/cookie/cookie.go +++ b/net/http/cookie/cookie.go @@ -8,7 +8,10 @@ import ( ) type ( + // Cookie describes a named HTTP cookie and the attributes used when it is + // written by [Cookie.Set]. Create one with [New]. Cookie struct { + // Name the name of the cookie Name string // Path the path of the created cookie Path string @@ -18,7 +21,7 @@ type ( MaxAge int // Expires the expires flag of the created cookie Expires time.Duration - // Secure the secure flag of the created cookie + // SameSite the same site flag of the created cookie SameSite http.SameSite // HTTPOnly the http only of the created cookie HTTPOnly bool @@ -27,66 +30,76 @@ type ( // DomainProvider function to retrieve the domain flag of the created cookie DomainProvider DomainProvider } + // Option configures a [Cookie] in [New] or overrides its attributes in + // [Cookie.Set]. Option func(options *Cookie) ) -// WithSecure middleware option +// WithSecure sets the Secure attribute. Defaults to true. func WithSecure(v bool) Option { return func(o *Cookie) { o.Secure = v } } -// WithHTTPOnly middleware option +// WithHTTPOnly sets the HttpOnly attribute. Defaults to true. func WithHTTPOnly(v bool) Option { return func(o *Cookie) { o.HTTPOnly = v } } -// WithMaxAge middleware option +// WithMaxAge sets the Max-Age attribute in seconds; see [http.Cookie.MaxAge] +// for the meaning of zero and negative values. Defaults to 0. func WithMaxAge(v int) Option { return func(o *Cookie) { o.MaxAge = v } } -// WithExpires middleware option +// WithExpires sets the lifetime used to compute the Expires attribute +// relative to the [TimeProvider]. Non-positive values omit Expires, which is +// the default. func WithExpires(v time.Duration) Option { return func(o *Cookie) { o.Expires = v } } -// WithPath middleware option +// WithPath sets the Path attribute. Defaults to "/". func WithPath(v string) Option { return func(o *Cookie) { o.Path = v } } -// WithSameSite middleware option +// WithSameSite sets the SameSite attribute. Defaults to +// [http.SameSiteDefaultMode]. func WithSameSite(v http.SameSite) Option { return func(o *Cookie) { o.SameSite = v } } -// WithTimeProvider middleware option +// WithTimeProvider sets the clock used to compute the Expires attribute. +// Defaults to [NewTimeProvider] without options. func WithTimeProvider(v TimeProvider) Option { return func(o *Cookie) { o.TimeProvider = v } } -// WithDomainProvider middleware option +// WithDomainProvider sets the function resolving the Domain attribute from the +// request. Defaults to [NewDomainProvider] without options. func WithDomainProvider(v DomainProvider) Option { return func(o *Cookie) { o.DomainProvider = v } } -// New return a new provider +// New returns a [Cookie] with the given name, configured by opts. Without +// options the cookie uses path "/", is Secure and HttpOnly, and uses +// [http.SameSiteDefaultMode]. Nil options are ignored. func New(name string, opts ...Option) Cookie { inst := Cookie{ Name: name, @@ -113,6 +126,9 @@ func New(name string, opts ...Option) Cookie { return inst } +// Delete expires the cookie on the client by writing it with an empty value +// and a negative Max-Age. It does nothing if r does not carry the cookie and +// returns any error from the domain provider. func (c Cookie) Delete(w http.ResponseWriter, r *http.Request) error { if _, err := r.Cookie(c.Name); errors.Is(err, http.ErrNoCookie) { return nil @@ -125,10 +141,15 @@ func (c Cookie) Delete(w http.ResponseWriter, r *http.Request) error { return nil } +// Get returns the cookie from r, or [http.ErrNoCookie] if it is not present. func (c Cookie) Get(r *http.Request) (*http.Cookie, error) { return r.Cookie(c.Name) } +// Set writes the cookie with the given value to w and returns it. opts +// override the cookie's attributes for this call only; the name and domain +// provider of c are always used. It returns an error if the domain provider +// rejects the request's domain. func (c Cookie) Set(w http.ResponseWriter, r *http.Request, value string, opts ...Option) (*http.Cookie, error) { domain, err := c.DomainProvider(r) if err != nil { diff --git a/net/http/cookie/doc.go b/net/http/cookie/doc.go new file mode 100644 index 00000000..4e03c74b --- /dev/null +++ b/net/http/cookie/doc.go @@ -0,0 +1,17 @@ +// Package cookie provides a declarative helper to read, write and delete an +// HTTP cookie with consistent attributes. +// +// A [Cookie] is created with [New] and functional [Option] values. Its domain +// is resolved per request by a [DomainProvider], which can restrict cookies to +// a set of allowed domains, and its expiry is computed from a [TimeProvider]: +// +// c := cookie.New("session", +// cookie.WithExpires(24*time.Hour), +// cookie.WithDomainProvider(cookie.NewDomainProvider( +// cookie.DomainProviderWithDomains("*.example.com"), +// )), +// ) +// if _, err := c.Set(w, r, value); err != nil { +// // handle error +// } +package cookie diff --git a/net/http/cookie/domainprovider.go b/net/http/cookie/domainprovider.go index 364b4412..3bfba149 100644 --- a/net/http/cookie/domainprovider.go +++ b/net/http/cookie/domainprovider.go @@ -9,34 +9,52 @@ import ( keelhttp "github.com/foomo/keel/utils/net/http" ) +// DomainProvider returns the cookie domain to use for the request r. type DomainProvider func(r *http.Request) (string, error) +// ErrDomainNotAllowed is returned by a [DomainProvider] created by +// [NewDomainProvider] when the request domain matches none of the allowed +// domains. var ErrDomainNotAllowed = errors.New("domain not allowed") type ( + // DomainProviderOptions configures [NewDomainProvider]. DomainProviderOptions struct { - Domains []string + // Domains lists the allowed domains; a "*." prefix also matches any + // subdomain. An empty list allows every domain. + Domains []string + // Mappings maps a request domain to the cookie domain to use instead. + // It is only consulted when Domains is not empty. Mappings map[string]string } + // DomainProviderOption configures [DomainProviderOptions]. DomainProviderOption func(options *DomainProviderOptions) ) +// GetDefaultDomainProviderOptions returns the default options, which allow +// every domain. func GetDefaultDomainProviderOptions() DomainProviderOptions { return DomainProviderOptions{} } +// DomainProviderWithDomains sets the allowed domains. func DomainProviderWithDomains(v ...string) DomainProviderOption { return func(o *DomainProviderOptions) { o.Domains = v } } +// DomainProviderWithMappings sets the domain mappings. func DomainProviderWithMappings(v map[string]string) DomainProviderOption { return func(o *DomainProviderOptions) { o.Mappings = v } } +// NewDomainProvider returns a [DomainProvider] that derives the domain from the +// request using [keelhttp.GetRequestDomain]. If allowed domains are +// configured, the domain is first replaced by its mapping, if any, and +// [ErrDomainNotAllowed] is returned unless it matches one of them. func NewDomainProvider(opts ...DomainProviderOption) DomainProvider { options := GetDefaultDomainProviderOptions() diff --git a/net/http/cookie/timeprovider.go b/net/http/cookie/timeprovider.go index a1b38ed1..66abe846 100644 --- a/net/http/cookie/timeprovider.go +++ b/net/http/cookie/timeprovider.go @@ -4,25 +4,35 @@ import ( "time" ) +// TimeProvider returns the current time used to compute cookie expiry. type TimeProvider func() time.Time type ( + // TimeProviderOptions configures [NewTimeProvider]. TimeProviderOptions struct { + // Offset is added to the current time. Offset time.Duration } + // TimeProviderOption configures [TimeProviderOptions]. TimeProviderOption func(options *TimeProviderOptions) ) +// GetDefaultTimeProviderOptions returns the default options, which use no +// offset. func GetDefaultTimeProviderOptions() TimeProviderOptions { return TimeProviderOptions{} } +// TimeProviderWithOffset sets the offset added to the current time. Defaults +// to 0. func TimeProviderWithOffset(v time.Duration) TimeProviderOption { return func(o *TimeProviderOptions) { o.Offset = v } } +// NewTimeProvider returns a [TimeProvider] reporting [time.Now] plus the +// configured offset. func NewTimeProvider(opts ...TimeProviderOption) TimeProvider { options := GetDefaultTimeProviderOptions() diff --git a/net/http/doc.go b/net/http/doc.go new file mode 100644 index 00000000..be145e71 --- /dev/null +++ b/net/http/doc.go @@ -0,0 +1,23 @@ +// Package http provides HTTP client and server constructors tuned for +// services running on Kubernetes, together with middleware composition and +// common header name constants. +// +// # Clients +// +// [NewInternalHTTPClient] targets other workloads in the same cluster and +// [NewExternalHTTPClient] targets third-party APIs on the public internet. +// Both are customized with [HTTPClientOption] values; transport options must +// be applied before wrapping options such as [HTTPClientWithTelemetry]: +// +// client := http.NewInternalHTTPClient( +// http.HTTPClientWithMaxConnsPerHost(64), +// http.HTTPClientWithTelemetry(), +// ) +// +// Create one client per upstream and reuse it for the lifetime of the process. +// +// # Servers +// +// [NewServer] returns a [net/http.Server] with production timeouts whose +// handler is wrapped with [Middleware] values via [Compose]. +package http diff --git a/net/http/header.go b/net/http/header.go index 7b62e867..3c53eea1 100644 --- a/net/http/header.go +++ b/net/http/header.go @@ -1,5 +1,7 @@ package http +// Canonical names of HTTP request and response headers, including common +// de-facto, Cloudflare, CORS, security and W3C trace context headers. const ( HeaderAccept = "Accept" HeaderAcceptEncoding = "Accept-Encoding" @@ -68,7 +70,7 @@ const ( HeaderXCSRFToken = "X-CSRF-Token" HeaderReferrerPolicy = "Referrer-Policy" - // Telementry + // Telemetry HeaderTraceParent = "Traceparent" HeaderTraceState = "Tracestate" diff --git a/net/http/headervalues.go b/net/http/headervalues.go index 94d1de38..b85dc91b 100644 --- a/net/http/headervalues.go +++ b/net/http/headervalues.go @@ -1,5 +1,7 @@ package http +// HeaderValueAuthorizationPrefix is the prefix of a bearer token in the +// Authorization header. const ( HeaderValueAuthorizationPrefix = "Bearer " ) diff --git a/net/http/log/doc.go b/net/http/log/doc.go new file mode 100644 index 00000000..0fdfd527 --- /dev/null +++ b/net/http/log/doc.go @@ -0,0 +1,4 @@ +// Package log provides helpers to attach a [github.com/foomo/keel/log.Labeler] +// to an HTTP request context, so code handling the request can collect +// additional log fields for it. +package log diff --git a/net/http/log/labeler.go b/net/http/log/labeler.go index fe545f9d..217652f2 100644 --- a/net/http/log/labeler.go +++ b/net/http/log/labeler.go @@ -7,20 +7,30 @@ import ( "github.com/foomo/keel/log" ) +// loggerLabelerContextKey is the context key under which the request +// [log.Labeler] is stored. const loggerLabelerContextKey log.LabelerContextKey = "github.com/foomo/keel/net/log.Labeler" +// LabelerFromContext returns the [log.Labeler] injected into ctx by +// [InjectLabelerIntoContext]. It reports false if none is present. func LabelerFromContext(ctx context.Context) (*log.Labeler, bool) { return log.LabelerFromContext(ctx, loggerLabelerContextKey) } +// LabelerFromRequest returns the [log.Labeler] injected into the context of r. +// It reports false if none is present. func LabelerFromRequest(r *http.Request) (*log.Labeler, bool) { return log.LabelerFromContext(r.Context(), loggerLabelerContextKey) } +// InjectLabelerIntoContext returns a copy of ctx carrying a fresh, empty +// [log.Labeler], together with that labeler. func InjectLabelerIntoContext(ctx context.Context) (context.Context, *log.Labeler) { return log.InjectLabeler(ctx, loggerLabelerContextKey) } +// InjectLabelerIntoRequest returns a shallow copy of r whose context carries a +// fresh, empty [log.Labeler], together with that labeler. func InjectLabelerIntoRequest(r *http.Request) (*http.Request, *log.Labeler) { ctx, labeler := InjectLabelerIntoContext(r.Context()) return r.WithContext(ctx), labeler diff --git a/net/http/middleware.go b/net/http/middleware.go index e01695dc..6da0efe8 100644 --- a/net/http/middleware.go +++ b/net/http/middleware.go @@ -6,9 +6,13 @@ import ( "go.uber.org/zap" ) -// Middleware your way to handle requests +// Middleware wraps next with additional request handling. l is the logger and +// name the name of the service the handler belongs to. type Middleware func(l *zap.Logger, name string, next http.Handler) http.Handler +// Compose wraps handler with the given middlewares and returns the result. +// Middlewares are applied in order, so the last one is the outermost and sees +// a request first. func Compose(l *zap.Logger, name string, handler http.Handler, middlewares ...Middleware) http.Handler { composed := func(l *zap.Logger, name string, next http.Handler) http.Handler { for _, middleware := range middlewares { diff --git a/net/http/middleware/basicauth.go b/net/http/middleware/basicauth.go index ed919394..e4b6e6f0 100644 --- a/net/http/middleware/basicauth.go +++ b/net/http/middleware/basicauth.go @@ -16,27 +16,35 @@ import ( ) type ( + // BasicAuthOptions configures the [BasicAuth] middleware. BasicAuthOptions struct { + // Realm is the realm sent in the WWW-Authenticate challenge. Realm string } + // BasicAuthOption configures [BasicAuthOptions]. BasicAuthOption func(*BasicAuthOptions) ) -// GetDefaultBasicAuthOptions returns the default options +// GetDefaultBasicAuthOptions returns the default options with the realm "Restricted". func GetDefaultBasicAuthOptions() BasicAuthOptions { return BasicAuthOptions{ Realm: "Restricted", } } -// BasicAuthWithRealm middleware option +// BasicAuthWithRealm sets the realm sent in the WWW-Authenticate challenge. +// Defaults to "Restricted". func BasicAuthWithRealm(v string) BasicAuthOption { return func(o *BasicAuthOptions) { o.Realm = v } } -// BasicAuth middleware +// BasicAuth returns a middleware that requires HTTP basic authentication. +// The username is compared in constant time and the password is verified +// against passwordHash, which must be a bcrypt hash. Missing or invalid +// credentials are answered with 401 Unauthorized and a WWW-Authenticate +// challenge. func BasicAuth(username string, passwordHash []byte, opts ...BasicAuthOption) keelhttp.Middleware { options := GetDefaultBasicAuthOptions() @@ -49,7 +57,7 @@ func BasicAuth(username string, passwordHash []byte, opts ...BasicAuthOption) ke return BasicAuthWithOptions(username, passwordHash, options) } -// BasicAuthWithOptions middleware +// BasicAuthWithOptions is like [BasicAuth] but takes fully populated options. func BasicAuthWithOptions(username string, passwordHash []byte, opts BasicAuthOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/cookietokenprovider.go b/net/http/middleware/cookietokenprovider.go index 790d20e5..3d68f4a4 100644 --- a/net/http/middleware/cookietokenprovider.go +++ b/net/http/middleware/cookietokenprovider.go @@ -7,15 +7,20 @@ import ( ) type ( + // CookieTokenProviderOptions configures [CookieTokenProvider]. It has no + // fields and exists for API symmetry with the other token providers. CookieTokenProviderOptions struct{} - CookieTokenProviderOption func(*CookieTokenProviderOptions) + // CookieTokenProviderOption configures [CookieTokenProviderOptions]. + CookieTokenProviderOption func(*CookieTokenProviderOptions) ) -// GetDefaultCookieTokenOptions returns the default options +// GetDefaultCookieTokenOptions returns the default options. func GetDefaultCookieTokenOptions() CookieTokenProviderOptions { return CookieTokenProviderOptions{} } +// CookieTokenProvider returns a [TokenProvider] that reads the token from the +// cookie named cookieName. A missing cookie yields an empty token and no error. func CookieTokenProvider(cookieName string, opts ...CookieTokenProviderOption) TokenProvider { options := GetDefaultCookieTokenOptions() diff --git a/net/http/middleware/cors.go b/net/http/middleware/cors.go index d0ee7faf..7c16486d 100644 --- a/net/http/middleware/cors.go +++ b/net/http/middleware/cors.go @@ -13,18 +13,36 @@ import ( ) type ( + // CORSOptions configures the [CORS] middleware. CORSOptions struct { - AllowOrigins []string - AllowMethods []string - AllowHeaders []string + // AllowOrigins lists the allowed origins. "*" allows any origin. Entries + // may contain the wildcards "*" (any sequence) and "?" (any single + // character), e.g. "https://*.example.com". + AllowOrigins []string + // AllowMethods lists the methods sent in Access-Control-Allow-Methods + // on preflight responses. + AllowMethods []string + // AllowHeaders lists the headers sent in Access-Control-Allow-Headers on + // preflight responses. When empty, the request's + // Access-Control-Request-Headers value is echoed. + AllowHeaders []string + // AllowCredentials reports whether Access-Control-Allow-Credentials is + // sent. When set together with the "*" origin, the request origin is + // echoed instead of "*". AllowCredentials bool - ExposeHeaders []string - MaxAge int + // ExposeHeaders lists the headers sent in Access-Control-Expose-Headers + // on non-preflight responses. + ExposeHeaders []string + // MaxAge is the Access-Control-Max-Age in seconds sent on preflight + // responses. Zero omits the header. + MaxAge int } + // CORSOption configures [CORSOptions]. CORSOption func(*CORSOptions) ) -// GetDefaultCORSOptions returns the default options +// GetDefaultCORSOptions returns the default options, which allow any origin +// and the methods GET, HEAD, PUT, PATCH, POST and DELETE. func GetDefaultCORSOptions() CORSOptions { return CORSOptions{ AllowOrigins: []string{"*"}, @@ -32,49 +50,53 @@ func GetDefaultCORSOptions() CORSOptions { } } -// CORSWithAllowOrigins middleware option +// CORSWithAllowOrigins sets [CORSOptions.AllowOrigins]. func CORSWithAllowOrigins(v ...string) CORSOption { return func(o *CORSOptions) { o.AllowOrigins = v } } -// CORSWithAllowMethods middleware option +// CORSWithAllowMethods sets [CORSOptions.AllowMethods]. func CORSWithAllowMethods(v ...string) CORSOption { return func(o *CORSOptions) { o.AllowMethods = v } } -// CORSWithAllowHeaders middleware option +// CORSWithAllowHeaders sets [CORSOptions.AllowHeaders]. func CORSWithAllowHeaders(v ...string) CORSOption { return func(o *CORSOptions) { o.AllowHeaders = v } } -// CORSWithAllowCredentials middleware option +// CORSWithAllowCredentials sets [CORSOptions.AllowCredentials]. func CORSWithAllowCredentials(v bool) CORSOption { return func(o *CORSOptions) { o.AllowCredentials = v } } -// CORSWithExposeHeaders middleware option +// CORSWithExposeHeaders sets [CORSOptions.ExposeHeaders]. func CORSWithExposeHeaders(v ...string) CORSOption { return func(o *CORSOptions) { o.ExposeHeaders = v } } -// CORSWithMaxAge middleware option +// CORSWithMaxAge sets [CORSOptions.MaxAge]. func CORSWithMaxAge(v int) CORSOption { return func(o *CORSOptions) { o.MaxAge = v } } -// CORS middleware +// CORS returns a middleware that handles cross-origin resource sharing. +// Preflight (OPTIONS) requests are answered with 204 No Content and never +// reach the next handler, regardless of whether the origin is allowed. Other +// requests are always passed on and receive the CORS response headers only +// when their origin is allowed. func CORS(opts ...CORSOption) keelhttp.Middleware { options := GetDefaultCORSOptions() @@ -87,7 +109,7 @@ func CORS(opts ...CORSOption) keelhttp.Middleware { return CORSWithOptions(options) } -// CORSWithOptions middleware +// CORSWithOptions is like [CORS] but takes fully populated options. func CORSWithOptions(opts CORSOptions) keelhttp.Middleware { allowOriginPatterns := make([]*regexp.Regexp, len(opts.AllowOrigins)) for i, origin := range opts.AllowOrigins { @@ -217,6 +239,7 @@ func CORSWithOptions(opts CORSOptions) keelhttp.Middleware { } } +// matchScheme reports whether domain and pattern have the same scheme. func matchScheme(domain, pattern string) bool { domScheme, _, domFound := strings.Cut(domain, ":") patScheme, _, patFound := strings.Cut(pattern, ":") @@ -224,7 +247,10 @@ func matchScheme(domain, pattern string) bool { return domFound && patFound && domScheme == patScheme } -// matchSubdomain compares authority with wildcard +// matchSubdomain reports whether domain matches pattern, comparing the +// dot-separated authority components right to left. A "*" component in +// pattern matches the remaining components, e.g. "https://*.example.com" +// matches "https://api.example.com". func matchSubdomain(domain, pattern string) bool { if !matchScheme(domain, pattern) { return false diff --git a/net/http/middleware/doc.go b/net/http/middleware/doc.go new file mode 100644 index 00000000..c5f440e1 --- /dev/null +++ b/net/http/middleware/doc.go @@ -0,0 +1,32 @@ +// Package middleware provides HTTP server middlewares for keel services. +// +// Every middleware is a [github.com/foomo/keel/net/http.Middleware] and is +// typically passed to an HTTP service or combined with +// [github.com/foomo/keel/net/http.Compose]: +// +// svs := service.NewHTTP(l, "api", ":8080", handler, +// middleware.Telemetry(), +// middleware.Logger(), +// middleware.Recover(), +// ) +// +// # Options +// +// Configurable middlewares follow the same pattern: Xxx(opts ...XxxOption) +// applies functional options to the defaults returned by GetDefaultXxxOptions +// and delegates to XxxWithOptions, which takes a fully populated XxxOptions. +// +// # Middlewares +// +// - Authentication: [BasicAuth], [TokenAuth], [JWT] and [RBAC], with the +// token sources [HeaderTokenProvider] and [CookieTokenProvider]. +// - Request context: [RequestID], [SessionID], [TrackingID] and [Referer]. +// - Observability: [Telemetry], [Logger] and [ResponseTime]. +// - Transport: [CORS], [GZip], [MaxRequestBodySize] and +// [ResponseController]. +// - Response headers: [ServerHeader] and [PoweredByHeader]. +// - Safety: [Recover]. +// +// [Skip] bypasses a middleware for requests matched by a [Skipper], such as +// [RequestURIBlacklistSkipper] or [RequestURIWhitelistSkipper]. +package middleware diff --git a/net/http/middleware/gzip.go b/net/http/middleware/gzip.go index 19b6a924..0b39d33e 100644 --- a/net/http/middleware/gzip.go +++ b/net/http/middleware/gzip.go @@ -18,16 +18,22 @@ import ( ) type ( + // GZipOptions configures the [GZip] middleware. GZipOptions struct { + // CompressionLevel is the gzip level used to compress responses. CompressionLevel int - MinSize int + // MinSize is the minimum response body size in bytes to compress. + MinSize int // MaxDecompressedSize caps the number of decompressed request body bytes // accepted (guards against decompression bombs). Zero means unlimited. MaxDecompressedSize int64 } + // GZipOption configures [GZipOptions]. GZipOption func(*GZipOptions) ) +// DefaultGZipOptions holds the defaults used by [GZip]: the default +// compression level, a minimum size of 1024 bytes and no decompression limit. var DefaultGZipOptions = GZipOptions{ CompressionLevel: gzip.DefaultCompression, MinSize: 1024, @@ -55,7 +61,10 @@ func GZipWithMaxDecompressedSize(v int64) GZipOption { } } -// GZip middleware +// GZip returns a middleware that compresses responses for clients accepting +// gzip and decompresses request bodies sent with Content-Encoding: gzip. The +// Content-Encoding and Content-Length request headers are removed for +// decompressed bodies. Malformed gzip bodies are answered with 400 Bad Request. func GZip(opts ...GZipOption) keelhttp.Middleware { options := DefaultGZipOptions @@ -68,7 +77,8 @@ func GZip(opts ...GZipOption) keelhttp.Middleware { return GZipWithOptions(options) } -// GZipWithOptions middleware +// GZipWithOptions is like [GZip] but takes fully populated options. The +// returned middleware panics when applied if the options are invalid. func GZipWithOptions(opts GZipOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { pool := sync.Pool{ diff --git a/net/http/middleware/headertokenprovider.go b/net/http/middleware/headertokenprovider.go index 3b96b330..55c8f2e4 100644 --- a/net/http/middleware/headertokenprovider.go +++ b/net/http/middleware/headertokenprovider.go @@ -10,14 +10,19 @@ import ( ) type ( + // HeaderTokenProviderOptions configures [HeaderTokenProvider]. HeaderTokenProviderOptions struct { + // Prefix is the required value prefix, stripped from the token. Prefix string + // Header is the name of the request header holding the token. Header string } + // HeaderTokenProviderOption configures [HeaderTokenProviderOptions]. HeaderTokenProviderOption func(*HeaderTokenProviderOptions) ) -// GetDefaultHeaderTokenOptions returns the default options +// GetDefaultHeaderTokenOptions returns the default options, which read a +// bearer token from the Authorization header. func GetDefaultHeaderTokenOptions() HeaderTokenProviderOptions { return HeaderTokenProviderOptions{ Prefix: keelhttp.HeaderValueAuthorizationPrefix, @@ -25,20 +30,25 @@ func GetDefaultHeaderTokenOptions() HeaderTokenProviderOptions { } } -// HeaderTokenProviderWithPrefix middleware option +// HeaderTokenProviderWithPrefix sets the required value prefix. Defaults to +// "Bearer ". func HeaderTokenProviderWithPrefix(v string) HeaderTokenProviderOption { return func(o *HeaderTokenProviderOptions) { o.Prefix = v } } -// HeaderTokenProviderWithHeader middleware option +// HeaderTokenProviderWithHeader sets the request header holding the token. +// Defaults to Authorization. func HeaderTokenProviderWithHeader(v string) HeaderTokenProviderOption { return func(o *HeaderTokenProviderOptions) { o.Header = v } } +// HeaderTokenProvider returns a [TokenProvider] that reads the token from a +// request header and strips the configured prefix. A missing header yields an +// empty token and no error; a value without the prefix yields an error. func HeaderTokenProvider(opts ...HeaderTokenProviderOption) TokenProvider { options := GetDefaultHeaderTokenOptions() diff --git a/net/http/middleware/jwt.go b/net/http/middleware/jwt.go index 1b0b7689..ae504400 100644 --- a/net/http/middleware/jwt.go +++ b/net/http/middleware/jwt.go @@ -15,57 +15,84 @@ import ( ) type ( + // JWTOptions configures the [JWT] middleware. JWTOptions struct { - SetContext bool - TokenProvider TokenProvider - ClaimsProvider JWTClaimsProvider - ClaimsHandler JWTClaimsHandler + // SetContext reports whether a present token is parsed, validated and + // its claims stored in the request context. When false, a present + // token is neither parsed nor validated and the request is passed on + // unchanged. + SetContext bool + // TokenProvider extracts the token from the request. + TokenProvider TokenProvider + // ClaimsProvider returns the claims value a token is parsed into. + ClaimsProvider JWTClaimsProvider + // ClaimsHandler is called with the claims of a valid token. + ClaimsHandler JWTClaimsHandler + // MissingTokenHandler is called when the request carries no token. MissingTokenHandler JWTMissingTokenHandler + // InvalidTokenHandler is called when a token parses but is not valid. InvalidTokenHandler JWTInvalidTokenHandler - ErrorHandler JWTErrorHandler + // ErrorHandler is called when parsing or validating a token fails. + ErrorHandler JWTErrorHandler } - JWTOption func(*JWTOptions) - JWTClaimsProvider func() gojwt.Claims - JWTClaimsHandler func(*zap.Logger, http.ResponseWriter, *http.Request, gojwt.Claims) bool - JWTErrorHandler func(*zap.Logger, http.ResponseWriter, *http.Request, error) bool + // JWTOption configures [JWTOptions]. + JWTOption func(*JWTOptions) + // JWTClaimsProvider returns a new, empty claims value to parse a token + // into. It is called once per request. + JWTClaimsProvider func() gojwt.Claims + // JWTClaimsHandler is called with the claims of a valid token and reports + // whether the request continues, with the claims in its context. + JWTClaimsHandler func(*zap.Logger, http.ResponseWriter, *http.Request, gojwt.Claims) bool + // JWTErrorHandler is called when parsing a token fails and reports whether + // the request continues without claims. + JWTErrorHandler func(*zap.Logger, http.ResponseWriter, *http.Request, error) bool + // JWTMissingTokenHandler is called when the request carries no token. It + // returns optional claims to store in the context and reports whether the + // request continues. JWTMissingTokenHandler func(*zap.Logger, http.ResponseWriter, *http.Request) (gojwt.Claims, bool) + // JWTInvalidTokenHandler is called when a token parses but is not valid + // and reports whether the request continues without claims. JWTInvalidTokenHandler func(*zap.Logger, http.ResponseWriter, *http.Request, *gojwt.Token) bool ) -// DefaultJWTErrorHandler function +// DefaultJWTErrorHandler responds with 500 Internal Server Error and stops +// the request. func DefaultJWTErrorHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request, err error) bool { httputils.InternalServerError(l, w, r, errors.Wrap(err, "failed parse claims")) return false } -// DefaultJWTMissingTokenHandler function +// DefaultJWTMissingTokenHandler lets the request continue without claims. func DefaultJWTMissingTokenHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request) (gojwt.Claims, bool) { return nil, true } -// RequiredJWTMissingTokenHandler function +// RequiredJWTMissingTokenHandler responds with 400 Bad Request and stops the +// request. Use it to make a token mandatory. func RequiredJWTMissingTokenHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request) (gojwt.Claims, bool) { httputils.BadRequestServerError(l, w, r, errors.New("missing jwt token")) return nil, false } -// DefaultJWTInvalidTokenHandler function +// DefaultJWTInvalidTokenHandler responds with 400 Bad Request and stops the +// request. func DefaultJWTInvalidTokenHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request, token *gojwt.Token) bool { httputils.BadRequestServerError(l, w, r, errors.New("invalid jwt token")) return false } -// DefaultJWTClaimsProvider function +// DefaultJWTClaimsProvider returns an empty [gojwt.RegisteredClaims]. func DefaultJWTClaimsProvider() gojwt.Claims { return &gojwt.RegisteredClaims{} } -// DefaultJWTClaimsHandler function +// DefaultJWTClaimsHandler accepts all claims. func DefaultJWTClaimsHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request, claims gojwt.Claims) bool { return true } -// GetDefaultJWTOptions returns the default options +// GetDefaultJWTOptions returns the default options: SetContext enabled, a +// [HeaderTokenProvider] and the Default* handlers of this package. func GetDefaultJWTOptions() JWTOptions { return JWTOptions{ SetContext: true, @@ -78,55 +105,60 @@ func GetDefaultJWTOptions() JWTOptions { } } -// JWTWithTokenProvider middleware option +// JWTWithTokenProvider sets [JWTOptions.TokenProvider]. func JWTWithTokenProvider(v TokenProvider) JWTOption { return func(o *JWTOptions) { o.TokenProvider = v } } -// JWTWithClaimsProvider middleware option +// JWTWithClaimsProvider sets [JWTOptions.ClaimsProvider]. func JWTWithClaimsProvider(v JWTClaimsProvider) JWTOption { return func(o *JWTOptions) { o.ClaimsProvider = v } } -// JWTWithClaimsHandler middleware option +// JWTWithClaimsHandler sets [JWTOptions.ClaimsHandler]. func JWTWithClaimsHandler(v JWTClaimsHandler) JWTOption { return func(o *JWTOptions) { o.ClaimsHandler = v } } -// JWTWithInvalidTokenHandler middleware option +// JWTWithInvalidTokenHandler sets [JWTOptions.InvalidTokenHandler]. func JWTWithInvalidTokenHandler(v JWTInvalidTokenHandler) JWTOption { return func(o *JWTOptions) { o.InvalidTokenHandler = v } } -// JWTWithMissingTokenHandler middleware option +// JWTWithMissingTokenHandler sets [JWTOptions.MissingTokenHandler]. func JWTWithMissingTokenHandler(v JWTMissingTokenHandler) JWTOption { return func(o *JWTOptions) { o.MissingTokenHandler = v } } -// JWTWithErrorHandler middleware option +// JWTWithErrorHandler sets [JWTOptions.ErrorHandler]. func JWTWithErrorHandler(v JWTErrorHandler) JWTOption { return func(o *JWTOptions) { o.ErrorHandler = v } } +// JWTWithSetContext sets [JWTOptions.SetContext]. Defaults to true. func JWTWithSetContext(v bool) JWTOption { return func(o *JWTOptions) { o.SetContext = v } } -// JWT middleware +// JWT returns a middleware that parses and validates the request's JWT with v +// and stores the claims in the request context under contextKey. Requests +// whose context already holds a value for contextKey are passed on unchanged. +// A failing [TokenProvider] is answered with 400 Bad Request; all other +// outcomes are delegated to the handlers in [JWTOptions]. func JWT(v *jwt.JWT, contextKey any, opts ...JWTOption) keelhttp.Middleware { options := GetDefaultJWTOptions() @@ -139,7 +171,7 @@ func JWT(v *jwt.JWT, contextKey any, opts ...JWTOption) keelhttp.Middleware { return JWTWithOptions(v, contextKey, options) } -// JWTWithOptions middleware +// JWTWithOptions is like [JWT] but takes fully populated options. func JWTWithOptions(v *jwt.JWT, contextKey any, opts JWTOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/logger.go b/net/http/middleware/logger.go index 6fae87bb..67116bc6 100644 --- a/net/http/middleware/logger.go +++ b/net/http/middleware/logger.go @@ -14,16 +14,26 @@ import ( ) type ( + // LoggerOptions configures the [Logger] middleware. LoggerOptions struct { - Message string - MinWarnCode int - MinErrorCode int + // Message is the log message. + Message string + // MinWarnCode is the status code from which requests are logged at + // warn level. Zero disables it. + MinWarnCode int + // MinErrorCode is the status code from which requests are logged at + // error level. Zero disables it. + MinErrorCode int + // InjectLabeler reports whether a log labeler is injected into the + // request so downstream handlers can add fields to the log entry. InjectLabeler bool } + // LoggerOption configures [LoggerOptions]. LoggerOption func(*LoggerOptions) ) -// GetDefaultLoggerOptions returns the default options +// GetDefaultLoggerOptions returns the default options: message "handled http +// request", warn from 400, error from 500 and labeler injection enabled. func GetDefaultLoggerOptions() LoggerOptions { return LoggerOptions{ Message: "handled http request", @@ -33,7 +43,9 @@ func GetDefaultLoggerOptions() LoggerOptions { } } -// Logger middleware +// Logger returns a middleware that logs one entry per request once the +// handler returns. The entry carries request fields, duration, status code, +// response size and any labeler fields; its level depends on the status code. func Logger(opts ...LoggerOption) keelhttp.Middleware { options := GetDefaultLoggerOptions() @@ -46,35 +58,37 @@ func Logger(opts ...LoggerOption) keelhttp.Middleware { return LoggerWithOptions(options) } -// LoggerWithMessage middleware option +// LoggerWithMessage sets the log message. func LoggerWithMessage(v string) LoggerOption { return func(o *LoggerOptions) { o.Message = v } } -// LoggerWithMinWarnCode middleware option +// LoggerWithMinWarnCode sets the status code from which requests are logged +// at warn level. Defaults to 400. func LoggerWithMinWarnCode(v int) LoggerOption { return func(o *LoggerOptions) { o.MinWarnCode = v } } -// LoggerWithMinErrorCode middleware option +// LoggerWithMinErrorCode sets the status code from which requests are logged +// at error level. Defaults to 500. func LoggerWithMinErrorCode(v int) LoggerOption { return func(o *LoggerOptions) { o.MinErrorCode = v } } -// LoggerWithInjectLabeler middleware option +// LoggerWithInjectLabeler sets [LoggerOptions.InjectLabeler]. Defaults to true. func LoggerWithInjectLabeler(v bool) LoggerOption { return func(o *LoggerOptions) { o.InjectLabeler = v } } -// LoggerWithOptions middleware +// LoggerWithOptions is like [Logger] but takes fully populated options. func LoggerWithOptions(opts LoggerOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/poweredbyheader.go b/net/http/middleware/poweredbyheader.go index 605d2029..087901ea 100644 --- a/net/http/middleware/poweredbyheader.go +++ b/net/http/middleware/poweredbyheader.go @@ -10,14 +10,19 @@ import ( ) type ( + // PoweredByHeaderOptions configures the [PoweredByHeader] middleware. PoweredByHeaderOptions struct { - Header string + // Header is the name of the response header. + Header string + // Message is not used; the header value is the service name. Message string } + // PoweredByHeaderOption configures [PoweredByHeaderOptions]. PoweredByHeaderOption func(*PoweredByHeaderOptions) ) -// GetDefaultPoweredByHeaderOptions returns the default options +// GetDefaultPoweredByHeaderOptions returns the default options using the +// X-Powered-By header. func GetDefaultPoweredByHeaderOptions() PoweredByHeaderOptions { return PoweredByHeaderOptions{ Header: keelhttp.HeaderXPoweredBy, @@ -25,7 +30,8 @@ func GetDefaultPoweredByHeaderOptions() PoweredByHeaderOptions { } } -// PoweredByHeader middleware +// PoweredByHeader returns a middleware that adds a header, X-Powered-By by +// default, with the service name to every response. func PoweredByHeader(opts ...PoweredByHeaderOption) keelhttp.Middleware { options := GetDefaultPoweredByHeaderOptions() @@ -38,21 +44,23 @@ func PoweredByHeader(opts ...PoweredByHeaderOption) keelhttp.Middleware { return PoweredByHeaderWithOptions(options) } -// PoweredByHeaderWithHeader middleware option +// PoweredByHeaderWithHeader sets the response header name. Defaults to +// X-Powered-By. func PoweredByHeaderWithHeader(v string) PoweredByHeaderOption { return func(o *PoweredByHeaderOptions) { o.Header = v } } -// PoweredByHeaderWithMessage middleware option +// PoweredByHeaderWithMessage sets [PoweredByHeaderOptions.Message]. func PoweredByHeaderWithMessage(v string) PoweredByHeaderOption { return func(o *PoweredByHeaderOptions) { o.Message = v } } -// PoweredByHeaderWithOptions middleware +// PoweredByHeaderWithOptions is like [PoweredByHeader] but takes fully +// populated options. func PoweredByHeaderWithOptions(opts PoweredByHeaderOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/rbac.go b/net/http/middleware/rbac.go index 733d4bb2..e34f5fb5 100644 --- a/net/http/middleware/rbac.go +++ b/net/http/middleware/rbac.go @@ -18,10 +18,10 @@ import ( // // Typical wiring: // -// cfg, err := middleware.LoadRBACConfigFromFile("/etc/rbac.yaml") +// cfg, err := rbac.LoadConfigFromFile("/etc/rbac.yaml") // keellog.Must(l, err, "load rbac config") // -// matcher, err := middleware.NewRBACMatcher(cfg) +// matcher, err := rbac.NewMatcher(cfg) // keellog.Must(l, err, "compile rbac config") // // mw := middleware.RBAC(matcher, extractRoles) diff --git a/net/http/middleware/recover.go b/net/http/middleware/recover.go index 7a2c1e58..d25d32e1 100644 --- a/net/http/middleware/recover.go +++ b/net/http/middleware/recover.go @@ -14,27 +14,34 @@ import ( ) type ( + // RecoverOptions configures the [Recover] middleware. RecoverOptions struct { + // DisablePrintStack reports whether the stack trace is omitted from + // the log entry. DisablePrintStack bool } + // RecoverOption configures [RecoverOptions]. RecoverOption func(*RecoverOptions) ) -// GetDefaultRecoverOptions returns the default options +// GetDefaultRecoverOptions returns the default options, which include the +// stack trace. func GetDefaultRecoverOptions() RecoverOptions { return RecoverOptions{ DisablePrintStack: false, } } -// RecoverWithDisablePrintStack middleware option +// RecoverWithDisablePrintStack sets [RecoverOptions.DisablePrintStack]. func RecoverWithDisablePrintStack(v bool) RecoverOption { return func(o *RecoverOptions) { o.DisablePrintStack = v } } -// Recover middleware +// Recover returns a middleware that recovers panics from the next handler, +// logs them and responds with 500 Internal Server Error. Panics with +// [http.ErrAbortHandler] are re-raised. func Recover(opts ...RecoverOption) keelhttp.Middleware { options := GetDefaultRecoverOptions() @@ -47,7 +54,7 @@ func Recover(opts ...RecoverOption) keelhttp.Middleware { return RecoverWithOptions(options) } -// RecoverWithOptions middleware +// RecoverWithOptions is like [Recover] but takes fully populated options. func RecoverWithOptions(opts RecoverOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/referrer.go b/net/http/middleware/referrer.go index 8d1c298e..5ad673f8 100644 --- a/net/http/middleware/referrer.go +++ b/net/http/middleware/referrer.go @@ -12,14 +12,21 @@ import ( ) type ( + // RefererOptions configures the [Referer] middleware. RefererOptions struct { + // RequestHeader lists the request headers checked in order; the first + // non-empty value is used. RequestHeader []string - SetContext bool + // SetContext reports whether the referer is stored in the request + // context. + SetContext bool } + // RefererOption configures [RefererOptions]. RefererOption func(*RefererOptions) ) -// GetDefaultRefererOptions returns the default options +// GetDefaultRefererOptions returns the default options, which read the +// X-Referer and Referer headers and set the context. func GetDefaultRefererOptions() RefererOptions { return RefererOptions{ RequestHeader: []string{"X-Referer", "Referer"}, @@ -27,21 +34,23 @@ func GetDefaultRefererOptions() RefererOptions { } } -// RefererWithRequestHeader middleware option +// RefererWithRequestHeader appends headers to [RefererOptions.RequestHeader]. func RefererWithRequestHeader(v ...string) RefererOption { return func(o *RefererOptions) { o.RequestHeader = append(o.RequestHeader, v...) } } -// RefererWithSetContext middleware option +// RefererWithSetContext sets [RefererOptions.SetContext]. Defaults to true. func RefererWithSetContext(v bool) RefererOption { return func(o *RefererOptions) { o.SetContext = v } } -// Referer middleware +// Referer returns a middleware that reads the referer from the first +// non-empty configured request header, records it on the span and stores it +// in the request context. func Referer(opts ...RefererOption) keelhttp.Middleware { options := GetDefaultRefererOptions() @@ -54,7 +63,7 @@ func Referer(opts ...RefererOption) keelhttp.Middleware { return RefererWithOptions(options) } -// RefererWithOptions middleware +// RefererWithOptions is like [Referer] but takes fully populated options. func RefererWithOptions(opts RefererOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/requestid.go b/net/http/middleware/requestid.go index a444c9a0..367ffab8 100644 --- a/net/http/middleware/requestid.go +++ b/net/http/middleware/requestid.go @@ -14,18 +14,31 @@ import ( ) type ( + // RequestIDOptions configures the [RequestID] middleware. RequestIDOptions struct { - Provider provider.RequestID - RequestHeader []string - ResponseHeader string - SetRequestHeader bool + // Provider generates a request ID when the request carries none. + Provider provider.RequestID + // RequestHeader lists the request headers checked in order; the first + // non-empty value is used. + RequestHeader []string + // ResponseHeader is the name of the header the ID is written to. + ResponseHeader string + // SetRequestHeader reports whether the ID is set on the request, + // using the ResponseHeader name. + SetRequestHeader bool + // SetResponseHeader reports whether the ID is added to the response + // unless the header is already present. SetResponseHeader bool - SetContext bool + // SetContext reports whether the ID is stored in the request context. + SetContext bool } + // RequestIDOption configures [RequestIDOptions]. RequestIDOption func(*RequestIDOptions) ) -// GetDefaultRequestIDOptions returns the default options +// GetDefaultRequestIDOptions returns the default options, which read the +// X-Request-ID and Cf-Ray headers, write X-Request-ID and enable all Set +// flags. func GetDefaultRequestIDOptions() RequestIDOptions { return RequestIDOptions{ Provider: provider.DefaultRequestID, @@ -37,49 +50,56 @@ func GetDefaultRequestIDOptions() RequestIDOptions { } } -// RequestIDWithRequestHeader middleware option +// RequestIDWithRequestHeader appends headers to +// [RequestIDOptions.RequestHeader]. func RequestIDWithRequestHeader(v ...string) RequestIDOption { return func(o *RequestIDOptions) { o.RequestHeader = append(o.RequestHeader, v...) } } -// RequestIDWithResponseHeader middleware option +// RequestIDWithResponseHeader sets [RequestIDOptions.ResponseHeader]. +// Defaults to X-Request-ID. func RequestIDWithResponseHeader(v string) RequestIDOption { return func(o *RequestIDOptions) { o.ResponseHeader = v } } -// RequestIDWithSetRequestHeader middleware option +// RequestIDWithSetRequestHeader sets [RequestIDOptions.SetRequestHeader]. +// Defaults to true. func RequestIDWithSetRequestHeader(v bool) RequestIDOption { return func(o *RequestIDOptions) { o.SetRequestHeader = v } } -// RequestIDWithSetResponseHeader middleware option +// RequestIDWithSetResponseHeader sets [RequestIDOptions.SetResponseHeader]. +// Defaults to true. func RequestIDWithSetResponseHeader(v bool) RequestIDOption { return func(o *RequestIDOptions) { o.SetResponseHeader = v } } -// RequestIDWithGenerator middleware option +// RequestIDWithGenerator sets [RequestIDOptions.Provider]. Defaults to +// [provider.DefaultRequestID]. func RequestIDWithGenerator(v provider.RequestID) RequestIDOption { return func(o *RequestIDOptions) { o.Provider = v } } -// RequestIDWithSetContext middleware option +// RequestIDWithSetContext sets [RequestIDOptions.SetContext]. Defaults to true. func RequestIDWithSetContext(v bool) RequestIDOption { return func(o *RequestIDOptions) { o.SetContext = v } } -// RequestID middleware +// RequestID returns a middleware that ensures every request has an ID, taken +// from the first non-empty configured request header or generated by the +// provider, and propagates it to the request, response and context. func RequestID(opts ...RequestIDOption) keelhttp.Middleware { options := GetDefaultRequestIDOptions() @@ -92,7 +112,7 @@ func RequestID(opts ...RequestIDOption) keelhttp.Middleware { return RequestIDWithOptions(options) } -// RequestIDWithOptions middleware +// RequestIDWithOptions is like [RequestID] but takes fully populated options. func RequestIDWithOptions(opts RequestIDOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/requesturiblacklistskipper.go b/net/http/middleware/requesturiblacklistskipper.go index a67780dd..59b3d901 100644 --- a/net/http/middleware/requesturiblacklistskipper.go +++ b/net/http/middleware/requesturiblacklistskipper.go @@ -4,7 +4,8 @@ import ( "net/http" ) -// RequestURIBlacklistSkipper returns a HTTPMiddlewareConfig.Skipper which skips the given uris +// RequestURIBlacklistSkipper returns a [Skipper] that skips requests whose +// RequestURI, including the query, exactly matches one of uris. func RequestURIBlacklistSkipper(uris ...string) Skipper { urisMap := make(map[string]bool, len(uris)) for _, uri := range uris { diff --git a/net/http/middleware/requesturiwhitelistskipper.go b/net/http/middleware/requesturiwhitelistskipper.go index d6e3fc96..5aa7674d 100644 --- a/net/http/middleware/requesturiwhitelistskipper.go +++ b/net/http/middleware/requesturiwhitelistskipper.go @@ -4,7 +4,9 @@ import ( "net/http" ) -// RequestURIWhitelistSkipper returns a HTTPMiddlewareConfig.Skipper which skips all but the given uris +// RequestURIWhitelistSkipper returns a [Skipper] that skips all requests +// except those whose RequestURI, including the query, exactly matches one of +// uris. func RequestURIWhitelistSkipper(uris ...string) Skipper { urisMap := make(map[string]bool, len(uris)) for _, uri := range uris { diff --git a/net/http/middleware/responsetime.go b/net/http/middleware/responsetime.go index 28963d12..8ac430c1 100644 --- a/net/http/middleware/responsetime.go +++ b/net/http/middleware/responsetime.go @@ -12,15 +12,23 @@ import ( ) type ( + // ResponseTimeOptions configures the [ResponseTime] middleware. ResponseTimeOptions struct { - SetHeader bool - MaxDuration time.Duration + // SetHeader reports whether the X-Response-Time header, the time in + // microseconds until the response header is written, is set. + SetHeader bool + // MaxDuration is the duration above which a warning is logged. Zero + // disables the warning. + MaxDuration time.Duration + // MaxDurationMessage is the message of that warning. MaxDurationMessage string } + // ResponseTimeOption configures [ResponseTimeOptions]. ResponseTimeOption func(*ResponseTimeOptions) ) -// GetDefaultResponseTimeOptions returns the default options +// GetDefaultResponseTimeOptions returns the default options, which set the +// header and log no warnings. func GetDefaultResponseTimeOptions() ResponseTimeOptions { return ResponseTimeOptions{ SetHeader: true, @@ -28,28 +36,33 @@ func GetDefaultResponseTimeOptions() ResponseTimeOptions { } } -// ResponseTimeWithMaxDurationMessage middleware option +// ResponseTimeWithMaxDurationMessage sets the warning message. Defaults to +// "max response time exceeded". func ResponseTimeWithMaxDurationMessage(v string) ResponseTimeOption { return func(o *ResponseTimeOptions) { o.MaxDurationMessage = v } } -// ResponseTimeWithMaxDuration middleware option +// ResponseTimeWithMaxDuration sets the duration above which a warning is +// logged. Defaults to 0, which disables the warning. func ResponseTimeWithMaxDuration(v time.Duration) ResponseTimeOption { return func(o *ResponseTimeOptions) { o.MaxDuration = v } } -// ResponseTimeWithSetHeader middleware option +// ResponseTimeWithSetHeader sets [ResponseTimeOptions.SetHeader]. Defaults to +// true. func ResponseTimeWithSetHeader(v bool) ResponseTimeOption { return func(o *ResponseTimeOptions) { o.SetHeader = v } } -// ResponseTime middleware +// ResponseTime returns a middleware that measures the handler duration, sets +// the X-Response-Time header and logs a warning when the duration exceeds +// the configured maximum. func ResponseTime(opts ...ResponseTimeOption) keelhttp.Middleware { options := GetDefaultResponseTimeOptions() @@ -62,7 +75,8 @@ func ResponseTime(opts ...ResponseTimeOption) keelhttp.Middleware { return ResponseTimeWithOptions(options) } -// ResponseTimeWithOptions middleware +// ResponseTimeWithOptions is like [ResponseTime] but takes fully populated +// options. func ResponseTimeWithOptions(opts ResponseTimeOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/responsewriter.go b/net/http/middleware/responsewriter.go index 96b21ec3..a4690b09 100644 --- a/net/http/middleware/responsewriter.go +++ b/net/http/middleware/responsewriter.go @@ -9,7 +9,9 @@ import ( keelhttp "github.com/foomo/keel/net/http" ) -// responseWriter is a wrapper that includes that http statusCode and size for logging +// responseWriter wraps an [http.ResponseWriter] to record the status code and +// the number of body bytes written, and to optionally set the +// X-Response-Time header. type responseWriter struct { http.ResponseWriter writeResponseTimeHeader bool @@ -19,6 +21,8 @@ type responseWriter struct { size int } +// WrapResponseWriter wraps w to record the status code and body size. If w is +// already wrapped it is returned as is. func WrapResponseWriter(w http.ResponseWriter) *responseWriter { if wr, ok := w.(*responseWriter); ok { return wr @@ -30,14 +34,18 @@ func WrapResponseWriter(w http.ResponseWriter) *responseWriter { } } +// SetWriteResponseTimeHeader sets whether WriteHeader adds the +// X-Response-Time header with the microseconds elapsed since wrapping. func (w *responseWriter) SetWriteResponseTimeHeader(write bool) { w.writeResponseTimeHeader = write } +// Size returns the number of body bytes written. func (w *responseWriter) Size() int { return w.size } +// StatusCode returns the written status code, or 200 if none was written. func (w *responseWriter) StatusCode() int { if !w.wroteHeader { return http.StatusOK @@ -46,6 +54,7 @@ func (w *responseWriter) StatusCode() int { return w.statusCode } +// Status returns StatusCode as a decimal string. func (w *responseWriter) Status() string { return fmt.Sprintf("%d", w.StatusCode()) } @@ -64,6 +73,8 @@ func (w *responseWriter) Flush() { } } +// WriteHeader records statusCode and forwards it. Only the first call takes +// effect; later calls are ignored. func (w *responseWriter) WriteHeader(statusCode int) { if w.wroteHeader { return @@ -79,6 +90,8 @@ func (w *responseWriter) WriteHeader(statusCode int) { w.ResponseWriter.WriteHeader(statusCode) } +// Write writes b, writing a 200 status first if no header was written, and +// adds the written bytes to the size. func (w *responseWriter) Write(b []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) diff --git a/net/http/middleware/serverheader.go b/net/http/middleware/serverheader.go index c413989a..be9e49d0 100644 --- a/net/http/middleware/serverheader.go +++ b/net/http/middleware/serverheader.go @@ -12,21 +12,27 @@ import ( ) type ( + // ServerHeaderOptions configures the [ServerHeader] middleware. ServerHeaderOptions struct { + // Header is the name of the response header. Header string - Name string + // Name is the header value. When empty, the service name is used. + Name string } + // ServerHeaderOption configures [ServerHeaderOptions]. ServerHeaderOption func(*ServerHeaderOptions) ) -// GetDefaultServerHeaderOptions returns the default options +// GetDefaultServerHeaderOptions returns the default options using the Server +// header and the service name. func GetDefaultServerHeaderOptions() ServerHeaderOptions { return ServerHeaderOptions{ Header: keelhttp.HeaderServer, } } -// ServerHeader middleware +// ServerHeader returns a middleware that adds a header, Server by default, +// with the service name or the configured name to every response. func ServerHeader(opts ...ServerHeaderOption) keelhttp.Middleware { options := GetDefaultServerHeaderOptions() @@ -39,21 +45,22 @@ func ServerHeader(opts ...ServerHeaderOption) keelhttp.Middleware { return ServerHeaderWithOptions(options) } -// ServerHeaderWithName middleware option +// ServerHeaderWithName sets the header value. Defaults to the service name. func ServerHeaderWithName(v string) ServerHeaderOption { return func(o *ServerHeaderOptions) { o.Name = v } } -// ServerHeaderWithHeader middleware option +// ServerHeaderWithHeader sets the response header name. Defaults to Server. func ServerHeaderWithHeader(v string) ServerHeaderOption { return func(o *ServerHeaderOptions) { o.Header = v } } -// ServerHeaderWithOptions middleware +// ServerHeaderWithOptions is like [ServerHeader] but takes fully populated +// options. func ServerHeaderWithOptions(opts ServerHeaderOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { if opts.Name != "" { diff --git a/net/http/middleware/sessionid.go b/net/http/middleware/sessionid.go index 144d721a..0529bcc6 100644 --- a/net/http/middleware/sessionid.go +++ b/net/http/middleware/sessionid.go @@ -17,34 +17,45 @@ import ( ) type ( + // SessionIDOptions configures the [SessionID] middleware. SessionIDOptions struct { - // Header to look up the session id + // Header is the request header the session ID is read from and, with + // SetHeader, written to. Header string - // Cookie how to set the cookie + // Cookie is the cookie the session ID is read from and, with SetCookie, + // written to. Cookie cookie.Cookie - // Generator for the session ids + // Generator creates a session ID when SetCookie is enabled and the + // request carries none. Generator SessionIDGenerator - // SetCookie if true it will create a cookie if not exists + // SetCookie reports whether a session ID is generated and set as cookie + // when the request carries neither header nor cookie. SetCookie bool - // SetHeader if true it will set add a request header + // SetHeader reports whether the session ID is set as request header. SetHeader bool - // SetContext if true it will set the context key + // SetContext reports whether the session ID is stored in the request + // context. SetContext bool } - SessionIDOption func(*SessionIDOptions) + // SessionIDOption configures [SessionIDOptions]. + SessionIDOption func(*SessionIDOptions) + // SessionIDGenerator returns a new session ID. SessionIDGenerator func() string ) const ( + // DefaultSessionIDCookieName is the default name of the session ID cookie. DefaultSessionIDCookieName = "keel-session" ) -// DefaultSessionIDGenerator function +// DefaultSessionIDGenerator returns a random UUID string. func DefaultSessionIDGenerator() string { return uuid.New().String() } -// GetDefaultSessionIDOptions returns the default options +// GetDefaultSessionIDOptions returns the default options: the X-Session-ID header, a +// cookie named "keel-session", UUID generation, SetHeader and SetContext enabled +// and SetCookie disabled. func GetDefaultSessionIDOptions() SessionIDOptions { return SessionIDOptions{ Header: keelhttp.HeaderXSessionID, @@ -56,48 +67,53 @@ func GetDefaultSessionIDOptions() SessionIDOptions { } } +// SessionIDWithHeader sets [SessionIDOptions.Header]. Defaults to X-Session-ID. func SessionIDWithHeader(v string) SessionIDOption { return func(o *SessionIDOptions) { o.Header = v } } -// SessionIDWithSetCookie middleware option +// SessionIDWithSetCookie sets [SessionIDOptions.SetCookie]. Defaults to false. func SessionIDWithSetCookie(v bool) SessionIDOption { return func(o *SessionIDOptions) { o.SetCookie = v } } -// SessionIDWithSetHeader middleware option +// SessionIDWithSetHeader sets [SessionIDOptions.SetHeader]. Defaults to true. func SessionIDWithSetHeader(v bool) SessionIDOption { return func(o *SessionIDOptions) { o.SetHeader = v } } -// SessionIDWithSetContext middleware option +// SessionIDWithSetContext sets [SessionIDOptions.SetContext]. Defaults to true. func SessionIDWithSetContext(v bool) SessionIDOption { return func(o *SessionIDOptions) { o.SetContext = v } } -// SessionIDWithCookie middleware option +// SessionIDWithCookie sets [SessionIDOptions.Cookie]. func SessionIDWithCookie(v cookie.Cookie) SessionIDOption { return func(o *SessionIDOptions) { o.Cookie = v } } -// SessionIDWithGenerator middleware option +// SessionIDWithGenerator sets [SessionIDOptions.Generator]. Defaults to +// [DefaultSessionIDGenerator]. func SessionIDWithGenerator(v SessionIDGenerator) SessionIDOption { return func(o *SessionIDOptions) { o.Generator = v } } -// SessionID middleware +// SessionID returns a middleware that resolves the session ID from the request +// header or, failing that, the cookie. With SetCookie, a missing cookie is +// generated and set on both response and request. Cookie errors are answered +// with 500 Internal Server Error. func SessionID(opts ...SessionIDOption) keelhttp.Middleware { options := GetDefaultSessionIDOptions() @@ -110,7 +126,7 @@ func SessionID(opts ...SessionIDOption) keelhttp.Middleware { return SessionIDWithOptions(options) } -// SessionIDWithOptions middleware +// SessionIDWithOptions is like [SessionID] but takes fully populated options. func SessionIDWithOptions(opts SessionIDOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -156,7 +172,7 @@ func SessionIDWithOptions(opts SessionIDOptions) keelhttp.Middleware { } } -// SessionIDFromContext helper +// SessionIDFromContext returns the session ID stored in ctx, or "" if there is none. func SessionIDFromContext(ctx context.Context) string { if value, ok := keelhttpcontext.GetSessionID(ctx); ok { return value diff --git a/net/http/middleware/skip.go b/net/http/middleware/skip.go index 6c62ffed..cf9a4c18 100644 --- a/net/http/middleware/skip.go +++ b/net/http/middleware/skip.go @@ -7,6 +7,8 @@ import ( "go.uber.org/zap" ) +// Skip returns a middleware that applies mw unless one of skippers reports +// true for the request, in which case next is called directly. func Skip(mw keelhttp.Middleware, skippers ...Skipper) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { wrapped := mw(l, name, next) diff --git a/net/http/middleware/skipper.go b/net/http/middleware/skipper.go index 74de48d8..44e165c9 100644 --- a/net/http/middleware/skipper.go +++ b/net/http/middleware/skipper.go @@ -4,4 +4,5 @@ import ( "net/http" ) +// Skipper reports whether a middleware should be skipped for the request. type Skipper func(*http.Request) bool diff --git a/net/http/middleware/telemetry.go b/net/http/middleware/telemetry.go index 9f1b78fa..a42eb6f6 100644 --- a/net/http/middleware/telemetry.go +++ b/net/http/middleware/telemetry.go @@ -14,22 +14,33 @@ import ( ) type ( + // TelemetryOptions configures the [Telemetry] middleware. TelemetryOptions struct { - Name string - OtelOpts []otelhttp.Option + // Name is the operation name passed to otelhttp. When empty, the + // service name is used. + Name string + // OtelOpts are passed to [otelhttp.NewHandler]. + OtelOpts []otelhttp.Option + // InjectPropagationHeader reports whether the trace context is + // injected into the response headers. InjectPropagationHeader bool } + // TelemetryOption configures [TelemetryOptions]. TelemetryOption func(*TelemetryOptions) ) -// GetDefaultTelemetryOptions returns the default options +// GetDefaultTelemetryOptions returns the default options, which inject the +// propagation header. func GetDefaultTelemetryOptions() TelemetryOptions { return TelemetryOptions{ InjectPropagationHeader: true, } } -// Telemetry middleware +// Telemetry returns a middleware that instruments the handler with +// [otelhttp.NewHandler]. It optionally injects the trace context into the +// response headers and, for sampled spans, adds the trace and span IDs to the +// request's log labeler. func Telemetry(opts ...TelemetryOption) keelhttp.Middleware { options := GetDefaultTelemetryOptions() @@ -42,26 +53,30 @@ func Telemetry(opts ...TelemetryOption) keelhttp.Middleware { return TelemetryWithOptions(options) } +// TelemetryWithName sets [TelemetryOptions.Name]. Defaults to the service +// name. func TelemetryWithName(v string) TelemetryOption { return func(o *TelemetryOptions) { o.Name = v } } +// TelemetryWithInjectPropagationHeader sets +// [TelemetryOptions.InjectPropagationHeader]. Defaults to true. func TelemetryWithInjectPropagationHeader(v bool) TelemetryOption { return func(o *TelemetryOptions) { o.InjectPropagationHeader = v } } -// TelemetryWithOtelOpts middleware options +// TelemetryWithOtelOpts appends options passed to [otelhttp.NewHandler]. func TelemetryWithOtelOpts(v ...otelhttp.Option) TelemetryOption { return func(o *TelemetryOptions) { o.OtelOpts = append(o.OtelOpts, v...) } } -// TelemetryWithOptions middleware +// TelemetryWithOptions is like [Telemetry] but takes fully populated options. func TelemetryWithOptions(opts TelemetryOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { if opts.Name != "" { diff --git a/net/http/middleware/tokenauth.go b/net/http/middleware/tokenauth.go index 16397544..c755d230 100644 --- a/net/http/middleware/tokenauth.go +++ b/net/http/middleware/tokenauth.go @@ -13,28 +13,34 @@ import ( ) type ( + // TokenAuthOptions configures the [TokenAuth] middleware. TokenAuthOptions struct { - // TokenProvider function to retrieve the token + // TokenProvider extracts the token from the request. TokenProvider TokenProvider } + // TokenAuthOption configures [TokenAuthOptions]. TokenAuthOption func(*TokenAuthOptions) ) -// GetDefaultTokenAuthOptions returns the default options +// GetDefaultTokenAuthOptions returns the default options using a +// [HeaderTokenProvider]. func GetDefaultTokenAuthOptions() TokenAuthOptions { return TokenAuthOptions{ TokenProvider: HeaderTokenProvider(), } } -// TokenAuthWithTokenProvider middleware option +// TokenAuthWithTokenProvider sets [TokenAuthOptions.TokenProvider]. Defaults +// to [HeaderTokenProvider]. func TokenAuthWithTokenProvider(v TokenProvider) TokenAuthOption { return func(o *TokenAuthOptions) { o.TokenProvider = v } } -// TokenAuth middleware +// TokenAuth returns a middleware that requires the request token to equal +// token, compared in constant time. Missing or mismatching tokens and +// provider errors are answered with 401 Unauthorized. func TokenAuth(token string, opts ...TokenAuthOption) keelhttp.Middleware { options := GetDefaultTokenAuthOptions() @@ -47,7 +53,7 @@ func TokenAuth(token string, opts ...TokenAuthOption) keelhttp.Middleware { return TokenAuthWithOptions(token, options) } -// TokenAuthWithOptions middleware +// TokenAuthWithOptions is like [TokenAuth] but takes fully populated options. func TokenAuthWithOptions(token string, opts TokenAuthOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/net/http/middleware/tokenprovider.go b/net/http/middleware/tokenprovider.go index f2fd292e..308f56d2 100644 --- a/net/http/middleware/tokenprovider.go +++ b/net/http/middleware/tokenprovider.go @@ -4,4 +4,6 @@ import ( "net/http" ) +// TokenProvider extracts an authentication token from the request. It returns +// an empty token and a nil error when the request carries none. type TokenProvider func(r *http.Request) (string, error) diff --git a/net/http/middleware/trackingid.go b/net/http/middleware/trackingid.go index 3345f782..581b6f27 100644 --- a/net/http/middleware/trackingid.go +++ b/net/http/middleware/trackingid.go @@ -18,34 +18,45 @@ import ( ) type ( + // TrackingIDOptions configures the [TrackingID] middleware. TrackingIDOptions struct { - // Header to look up the tracking id + // Header is the request header the tracking ID is read from and, with + // SetHeader, written to. Header string - // Cookie how to set the cookie + // Cookie is the cookie the tracking ID is read from and, with SetCookie, + // written to. Cookie cookie.Cookie - // Generator for the tracking ids + // Generator creates a tracking ID when SetCookie is enabled and the + // request carries none. Generator TrackingIDGenerator - // SetCookie if true it will create a cookie if not exists + // SetCookie reports whether a tracking ID is generated and set as cookie + // when the request carries neither header nor cookie. SetCookie bool - // SetHeader if true it will set add a request header + // SetHeader reports whether the tracking ID is set as request header. SetHeader bool - // SetContext if true it will set the context key + // SetContext reports whether the tracking ID is stored in the request + // context. SetContext bool } - TrackingIDOption func(*TrackingIDOptions) + // TrackingIDOption configures [TrackingIDOptions]. + TrackingIDOption func(*TrackingIDOptions) + // TrackingIDGenerator returns a new tracking ID. TrackingIDGenerator func() string ) const ( + // DefaultTrackingIDCookieName is the default name of the tracking ID cookie. DefaultTrackingIDCookieName = "keel-tracking" ) -// DefaultTrackingIDGenerator function +// DefaultTrackingIDGenerator returns a random UUID string. func DefaultTrackingIDGenerator() string { return uuid.New().String() } -// GetDefaultTrackingIDOptions returns the default options +// GetDefaultTrackingIDOptions returns the default options: the X-Tracking-ID header, a +// cookie named "keel-tracking", UUID generation, SetHeader and SetContext enabled +// and SetCookie disabled. func GetDefaultTrackingIDOptions() TrackingIDOptions { return TrackingIDOptions{ Header: keelhttp.HeaderXTrackingID, @@ -57,48 +68,53 @@ func GetDefaultTrackingIDOptions() TrackingIDOptions { } } +// TrackingIDWithHeader sets [TrackingIDOptions.Header]. Defaults to X-Tracking-ID. func TrackingIDWithHeader(v string) TrackingIDOption { return func(o *TrackingIDOptions) { o.Header = v } } -// TrackingIDWithSetCookie middleware option +// TrackingIDWithSetCookie sets [TrackingIDOptions.SetCookie]. Defaults to false. func TrackingIDWithSetCookie(v bool) TrackingIDOption { return func(o *TrackingIDOptions) { o.SetCookie = v } } -// TrackingIDWithSetHeader middleware option +// TrackingIDWithSetHeader sets [TrackingIDOptions.SetHeader]. Defaults to true. func TrackingIDWithSetHeader(v bool) TrackingIDOption { return func(o *TrackingIDOptions) { o.SetHeader = v } } -// TrackingIDWithSetContext middleware option +// TrackingIDWithSetContext sets [TrackingIDOptions.SetContext]. Defaults to true. func TrackingIDWithSetContext(v bool) TrackingIDOption { return func(o *TrackingIDOptions) { o.SetContext = v } } -// TrackingIDWithCookie middleware option +// TrackingIDWithCookie sets [TrackingIDOptions.Cookie]. func TrackingIDWithCookie(v cookie.Cookie) TrackingIDOption { return func(o *TrackingIDOptions) { o.Cookie = v } } -// TrackingIDWithGenerator middleware option +// TrackingIDWithGenerator sets [TrackingIDOptions.Generator]. Defaults to +// [DefaultTrackingIDGenerator]. func TrackingIDWithGenerator(v TrackingIDGenerator) TrackingIDOption { return func(o *TrackingIDOptions) { o.Generator = v } } -// TrackingID middleware +// TrackingID returns a middleware that resolves the tracking ID from the request +// header or, failing that, the cookie. With SetCookie, a missing cookie is +// generated and set on both response and request. Cookie errors are answered +// with 500 Internal Server Error. func TrackingID(opts ...TrackingIDOption) keelhttp.Middleware { options := GetDefaultTrackingIDOptions() @@ -111,7 +127,7 @@ func TrackingID(opts ...TrackingIDOption) keelhttp.Middleware { return TrackingIDWithOptions(options) } -// TrackingIDWithOptions middleware +// TrackingIDWithOptions is like [TrackingID] but takes fully populated options. func TrackingIDWithOptions(opts TrackingIDOptions) keelhttp.Middleware { return func(l *zap.Logger, name string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -157,7 +173,7 @@ func TrackingIDWithOptions(opts TrackingIDOptions) keelhttp.Middleware { } } -// TrackingIDFromContext helper +// TrackingIDFromContext returns the tracking ID stored in ctx, or "" if there is none. func TrackingIDFromContext(ctx context.Context) string { if value, ok := keelhttpcontext.GetTrackingID(ctx); ok { return value diff --git a/net/http/provider/doc.go b/net/http/provider/doc.go new file mode 100644 index 00000000..5c9ed269 --- /dev/null +++ b/net/http/provider/doc.go @@ -0,0 +1,3 @@ +// Package provider provides pluggable value providers for HTTP middleware and +// round-trippers, such as [RequestID] generators. +package provider diff --git a/net/http/provider/requestidprovider.go b/net/http/provider/requestidprovider.go index f35cf7d8..12ea1d99 100644 --- a/net/http/provider/requestidprovider.go +++ b/net/http/provider/requestidprovider.go @@ -4,9 +4,10 @@ import ( "github.com/google/uuid" ) +// RequestID returns a new request ID. type RequestID func() string -// DefaultRequestID function +// DefaultRequestID returns a random (version 4) UUID string. func DefaultRequestID() string { return uuid.New().String() } diff --git a/net/http/rbac/compiledrule.go b/net/http/rbac/compiledrule.go index 9a953607..7eabad10 100644 --- a/net/http/rbac/compiledrule.go +++ b/net/http/rbac/compiledrule.go @@ -1,6 +1,6 @@ package rbac -// CompiledRule is an Rule prepared for matching. For prefix +// CompiledRule is a [Rule] prepared for matching. For prefix // rules the trailing "*" is stripped once at compile-time so the hot // path only does prefix comparisons. // diff --git a/net/http/rbac/decision.go b/net/http/rbac/decision.go index 67abc43c..e9dc64b4 100644 --- a/net/http/rbac/decision.go +++ b/net/http/rbac/decision.go @@ -1,7 +1,7 @@ package rbac -// Decision is the result of evaluating a single request against an -// Matcher. Rule is nil when no rule matched (the default policy +// Decision is the result of evaluating a single request against a +// [Matcher]. Rule is nil when no rule matched (the default policy // applied); Roles and Authed are the values returned by the extractor. type Decision struct { Outcome Outcome diff --git a/net/http/rbac/doc.go b/net/http/rbac/doc.go new file mode 100644 index 00000000..6b314ca4 --- /dev/null +++ b/net/http/rbac/doc.go @@ -0,0 +1,28 @@ +// Package rbac provides path-based, role-driven access control rules for +// HTTP requests. +// +// A [Config] declares a default [Policy] and a list of [Rule] values keyed by +// request path. [NewMatcher] validates and compiles it into a [Matcher], which +// classifies each request into an [Outcome] using the roles returned by a +// [RolesExtractor]. The [github.com/foomo/keel/net/http/middleware.RBAC] +// middleware turns these outcomes into pass-through, 401 or 403 responses. +// +// # Path matching +// +// An exact path rule takes precedence over prefix rules (paths ending in +// "*"), and among prefix rules the longest matching prefix wins. When no rule +// matches, the default policy applies. +// +// # Configuration +// +// Configurations are typically loaded from YAML with [LoadConfigFromFile]: +// +// defaultPolicy: deny +// rules: +// - path: "/api/admin/*" +// allowRoles: [admin] +// - path: "/api/public/*" +// +// The file rbac.schema.json in this package is a JSON schema for editor +// validation of such files. +package rbac diff --git a/net/http/rbac/helper.go b/net/http/rbac/helper.go index dfbd0719..eb52e8e2 100644 --- a/net/http/rbac/helper.go +++ b/net/http/rbac/helper.go @@ -7,19 +7,19 @@ import ( "gopkg.in/yaml.v3" ) -// LoadConfigFromFile reads an Config from the YAML file at path. +// LoadConfigFromFile reads a [Config] from the YAML file at path. // // Expected file shape: // // defaultPolicy: allow # or "deny" // rules: // - path: "/api/foo/*" -// allow: [admin] -// deny: [visitor] +// allowRoles: [admin] +// denyRoles: [visitor] // // LoadConfigFromFile only decodes the YAML; semantic errors (empty // paths, duplicate paths, an unknown DefaultPolicy) surface from -// NewMatcher. Keeping load and validation separate lets callers +// [NewMatcher]. Keeping load and validation separate lets callers // inspect the raw decoded shape before committing to a matcher. func LoadConfigFromFile(path string) (Config, error) { var cfg Config @@ -52,6 +52,7 @@ func rbacAllowed(r Rule, roles []string) bool { return rbacIntersects(r.AllowRoles, roles) } +// rbacIntersects reports whether a and b share at least one element. func rbacIntersects(a, b []string) bool { if len(a) == 0 || len(b) == 0 { return false diff --git a/net/http/rbac/matcher.go b/net/http/rbac/matcher.go index 5a2dd85b..af75e9ff 100644 --- a/net/http/rbac/matcher.go +++ b/net/http/rbac/matcher.go @@ -7,8 +7,9 @@ import ( "strings" ) -// Matcher is a validated, compiled Config ready to drive the -// middleware. Build one via NewMatcher and pass it to RBAC. +// Matcher is a validated, compiled [Config] ready to drive the +// middleware. Build one via [NewMatcher] and pass it to +// [github.com/foomo/keel/net/http/middleware.RBAC]. // // Matcher is safe for concurrent use: it is read-only after // construction. @@ -77,7 +78,9 @@ func NewMatcher(cfg Config) (*Matcher, error) { } // Evaluate classifies the request against the compiled rule set. The -// 401-vs-403 split is driven by the extractor's authenticated flag. +// roles and authenticated flag are obtained from extract. A denied request +// is classified as [OutcomeUnauthenticated] (401) when the caller is not +// authenticated, and as [OutcomeDeny] or [OutcomeNoRuleDeny] (403) otherwise. func (m *Matcher) Evaluate(extract RolesExtractor, r *http.Request) Decision { roles, authed := extract(r) diff --git a/net/http/rbac/outcome.go b/net/http/rbac/outcome.go index da9b11d3..d21f98ca 100644 --- a/net/http/rbac/outcome.go +++ b/net/http/rbac/outcome.go @@ -6,9 +6,18 @@ package rbac type Outcome string const ( - OutcomeAllow Outcome = "allow" - OutcomeDeny Outcome = "deny" + // OutcomeAllow means a rule matched and the caller's roles satisfy it. + OutcomeAllow Outcome = "allow" + // OutcomeDeny means a rule matched, the caller is authenticated and its + // roles do not satisfy the rule (403). + OutcomeDeny Outcome = "deny" + // OutcomeUnauthenticated means the request was denied, by a rule or by + // [PolicyDeny], and the caller is not authenticated (401). OutcomeUnauthenticated Outcome = "unauthenticated" - OutcomeNoRuleAllow Outcome = "no_rule_allow" - OutcomeNoRuleDeny Outcome = "no_rule_deny" + // OutcomeNoRuleAllow means no rule matched and [PolicyAllow] let the + // request through. + OutcomeNoRuleAllow Outcome = "no_rule_allow" + // OutcomeNoRuleDeny means no rule matched, the caller is authenticated and + // [PolicyDeny] rejected the request (403). + OutcomeNoRuleDeny Outcome = "no_rule_deny" ) diff --git a/net/http/roundtripware/circuitbreaker.go b/net/http/roundtripware/circuitbreaker.go index 88750d4f..2761b7fd 100644 --- a/net/http/roundtripware/circuitbreaker.go +++ b/net/http/roundtripware/circuitbreaker.go @@ -13,22 +13,22 @@ import ( ) var ( - // ErrCircuitBreaker is returned when the request failed because the circuit breaker did not let it go to the next - // RoundTripware. It wraps the two gobreaker errors (ErrTooManyRequests & ErrOpenState) so only one comparison is - // needed + // ErrCircuitBreaker is returned when the circuit breaker did not let the request pass to the next + // RoundTripware. It is joined with the underlying gobreaker error (ErrTooManyRequests or ErrOpenState) so a + // single [errors.Is] comparison is needed. ErrCircuitBreaker = errors.New("circuit breaker triggered") - // ErrIgnoreSuccessfulness can be returned by the IsSuccessful callback in order for the RoundTripware to ignore the - // result of the function + // ErrIgnoreSuccessfulness can be returned by the IsSuccessful callback to have the request counted neither as + // success nor as failure. ErrIgnoreSuccessfulness = errors.New("ignored successfulness") - // ErrReadFromActualBody when it is attempted to read from a body in the IsSuccessful callback that has not - // previously been copied. + // ErrReadFromActualBody is returned when the IsSuccessful callback reads a request or response body that was not + // copied (see [CircuitBreakerWithIsSuccessful]). ErrReadFromActualBody = errors.New("read from actual body") ) -// CircuitBreakerSettings is a copy of the gobreaker.Settings, except that the IsSuccessful function is omitted since we -// want to allow access to the request and response. See `CircuitBreakerWithIsSuccessful` for more. +// CircuitBreakerSettings is a copy of [gobreaker.Settings] without the IsSuccessful function, which is replaced by +// one with access to the request and response. See [CircuitBreakerWithIsSuccessful]. type CircuitBreakerSettings struct { // Name is the name of the CircuitBreaker. Name string @@ -53,14 +53,22 @@ type CircuitBreakerSettings struct { OnStateChange func(name string, from gobreaker.State, to gobreaker.State) } +// CircuitBreakerOptions configures the [CircuitBreaker] RoundTripware. type CircuitBreakerOptions struct { + // Counter, when non-nil, counts requests with state and error attributes. Counter metric.Int64Counter + // IsSuccessful decides whether a request counts as success (nil error) or + // failure. It receives copies of the request and response. IsSuccessful func(err error, req *http.Request, resp *http.Response) error - CopyReqBody bool + // CopyReqBody reports whether the request body is copied for IsSuccessful. + CopyReqBody bool + // CopyRespBody reports whether the response body is copied for IsSuccessful. CopyRespBody bool } +// NewDefaultCircuitBreakerOptions returns the default options: no counter, no +// body copies and an IsSuccessful that treats every transport error as failure. func NewDefaultCircuitBreakerOptions() *CircuitBreakerOptions { return &CircuitBreakerOptions{ Counter: nil, @@ -73,9 +81,11 @@ func NewDefaultCircuitBreakerOptions() *CircuitBreakerOptions { } } +// CircuitBreakerOption configures [CircuitBreakerOptions]. type CircuitBreakerOption func(opts *CircuitBreakerOptions) -// CircuitBreakerWithMetric adds a metric that counts the (un-)successful requests +// CircuitBreakerWithMetric adds an Int64Counter named meterName that counts +// successful and failed requests. It panics if the counter cannot be created. func CircuitBreakerWithMetric( meter metric.Meter, meterName string, @@ -95,6 +105,11 @@ func CircuitBreakerWithMetric( } } +// CircuitBreakerWithIsSuccessful sets the callback that classifies a request +// as success (nil) or failure (non-nil); returning [ErrIgnoreSuccessfulness] +// ignores the request. copyReqBody and copyRespBody enable copying the bodies +// so the callback can read them; reading a body that was not copied makes the +// RoundTripware return [ErrReadFromActualBody]. func CircuitBreakerWithIsSuccessful( isSuccessful func(err error, req *http.Request, resp *http.Response) error, copyReqBody bool, @@ -107,10 +122,11 @@ func CircuitBreakerWithIsSuccessful( } } -// CircuitBreaker returns a RoundTripper which wraps all the following RoundTripwares and the Handler with a circuit -// breaker. This will prevent further request once a certain number of requests failed. -// NOTE: It's strongly advised to add this Roundripware before the metric middleware (if both are used). As the measure- -// ments of the execution time will otherwise be falsified +// CircuitBreaker returns a RoundTripware which wraps all the following RoundTripwares and the Handler with a circuit +// breaker. It rejects requests with [ErrCircuitBreaker] once the breaker trips and logs state changes. +// +// It is strongly advised to add this RoundTripware before the metric RoundTripware (if both are used), as the +// execution time measurements will otherwise be falsified. func CircuitBreaker(set *CircuitBreakerSettings, opts ...CircuitBreakerOption) RoundTripware { // intitialize the options o := NewDefaultCircuitBreakerOptions() diff --git a/net/http/roundtripware/doc.go b/net/http/roundtripware/doc.go new file mode 100644 index 00000000..6f05b469 --- /dev/null +++ b/net/http/roundtripware/doc.go @@ -0,0 +1,23 @@ +// Package roundtripware provides HTTP client middlewares, called +// RoundTripwares, that wrap an [net/http.RoundTripper]. +// +// A [RoundTripware] wraps a [Handler] the same way a server middleware wraps +// an [net/http.Handler]. [NewRoundTripper] chains RoundTripwares around a +// parent transport: +// +// client := &http.Client{ +// Transport: roundtripware.NewRoundTripper(l, http.DefaultTransport, +// roundtripware.RequestID(), +// roundtripware.Logger(), +// ), +// } +// +// # RoundTripwares +// +// - Context propagation: [RequestID], [SessionID], [TrackingID] and +// [Referer] copy values from the request context into request headers. +// - Resilience: [CircuitBreaker], [Retry] and [Recover]. +// - Observability: [Logger] and the deprecated [Metric]. +// - Transport: [GZip]. +// - Debugging: [Dump], [DumpRequest] and [DumpResponse]. +package roundtripware diff --git a/net/http/roundtripware/dump.go b/net/http/roundtripware/dump.go index 21489176..795dfe41 100644 --- a/net/http/roundtripware/dump.go +++ b/net/http/roundtripware/dump.go @@ -8,7 +8,9 @@ import ( "go.uber.org/zap" ) -// Dump returns a RoundTripper which prints out the request & response object +// Dump returns a RoundTripware that prints the request and response bodies to +// stdout, pretty-printing JSON and msgpack. Bodies are only printed when a +// Content-Type header is set. func Dump() RoundTripware { return func(l *zap.Logger, next Handler) Handler { return func(r *http.Request) (*http.Response, error) { @@ -21,7 +23,7 @@ func Dump() RoundTripware { } } -// DumpRequest returns a RoundTripper which prints out the request object +// DumpRequest is like [Dump] but prints only the request body. func DumpRequest() RoundTripware { return func(l *zap.Logger, next Handler) Handler { return func(r *http.Request) (*http.Response, error) { @@ -37,7 +39,7 @@ func DumpRequest() RoundTripware { } } -// DumpResponse returns a RoundTripper which prints out the response object +// DumpResponse is like [Dump] but prints only the response body. func DumpResponse() RoundTripware { return func(l *zap.Logger, next Handler) Handler { return func(r *http.Request) (*http.Response, error) { diff --git a/net/http/roundtripware/gzip.go b/net/http/roundtripware/gzip.go index 951e9949..6dc2884d 100644 --- a/net/http/roundtripware/gzip.go +++ b/net/http/roundtripware/gzip.go @@ -16,14 +16,19 @@ import ( ) type ( + // GZipOptions configures the [GZip] RoundTripware. GZipOptions struct { + // CompressionLevel is the gzip compression level. CompressionLevel int - MinSize int + // MinSize is the minimum request Content-Length in bytes to compress. + MinSize int } + // GZipOption configures [GZipOptions]. GZipOption func(*GZipOptions) ) -// DefaultGZipOptions returns the default options +// DefaultGZipOptions holds the defaults used by [GZip]: the default +// compression level and a minimum size of 1024 bytes. var DefaultGZipOptions = GZipOptions{ CompressionLevel: gzip.DefaultCompression, MinSize: 1024, @@ -36,14 +41,16 @@ func GZipWithLevel(v int) GZipOption { } } -// GZipWithMinSize allows setting a minimum response body length to apply gzip compression (default: 1400 bytes). +// GZipWithMinSize allows setting a minimum request body length to apply gzip compression (default: 1024 bytes). func GZipWithMinSize(v int) GZipOption { return func(o *GZipOptions) { o.MinSize = v } } -// GZip returns a RoundTripware which logs all requests +// GZip returns a RoundTripware that gzip-compresses request bodies whose +// Content-Length is at least MinSize and that are not already gzip encoded, +// and transparently decompresses gzip responses. func GZip(opts ...GZipOption) RoundTripware { o := DefaultGZipOptions diff --git a/net/http/roundtripware/helper.go b/net/http/roundtripware/helper.go index 64ad0080..a387e579 100644 --- a/net/http/roundtripware/helper.go +++ b/net/http/roundtripware/helper.go @@ -12,6 +12,8 @@ import ( "github.com/tinylib/msgp/msgp" ) +// readBodyPretty reads original fully and returns a replacement reader with +// the same content together with the body, indented if it is JSON or msgpack. func readBodyPretty(contentType string, original io.ReadCloser) (io.ReadCloser, string) { var ( bs bytes.Buffer @@ -68,6 +70,8 @@ func (failureToReadBody) Close() error { return nil } // emptyBody is an instance of empty reader. var emptyBody = io.NopCloser(strings.NewReader("")) +// copyRequest clones req for the IsSuccessful callback. With body, the body is +// drained and duplicated; otherwise the copy's body fails on read with errNoBody. func copyRequest(req *http.Request, body bool) (*http.Request, error) { // we don't care about the context, since it is only used for the isSuccessful check out := req.Clone(context.Background()) @@ -97,6 +101,7 @@ func copyRequest(req *http.Request, body bool) (*http.Request, error) { return out, nil } +// copyResponse is like copyRequest for responses. func copyResponse(resp *http.Response, body bool) (*http.Response, error) { // we don't care about the context, since it is only used for the isSuccessful check out := new(http.Response) @@ -126,7 +131,8 @@ func copyResponse(resp *http.Response, body bool) (*http.Response, error) { return out, nil } -// copied from httputil +// drainBody reads b into memory and returns two readers with its content. +// It is copied from net/http/httputil. func drainBody(b io.ReadCloser) (io.ReadCloser, io.ReadCloser, error) { var err error diff --git a/net/http/roundtripware/labeler.go b/net/http/roundtripware/labeler.go index f59cbfff..4ec5e046 100644 --- a/net/http/roundtripware/labeler.go +++ b/net/http/roundtripware/labeler.go @@ -6,6 +6,7 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) +// labelerContextKeyType is the type of the context key holding the labeler. type labelerContextKeyType int const labelerContextKey labelerContextKeyType = 0 @@ -14,10 +15,9 @@ func injectLabeler(ctx context.Context, l *otelhttp.Labeler) context.Context { return context.WithValue(ctx, labelerContextKey, l) } -// LabelerFromContext retrieves a Labeler instance from the provided context if -// one is available. If no Labeler was found in the provided context a new, empty -// Labeler is returned and the second return value is false. In this case it is -// safe to use the Labeler but any attributes added to it will not be used. +// LabelerFromContext returns the [otelhttp.Labeler] stored in ctx. If there is +// none, a new, empty Labeler is created and returned together with a derived +// context that holds it. func LabelerFromContext(ctx context.Context) (context.Context, *otelhttp.Labeler) { l, ok := ctx.Value(labelerContextKey).(*otelhttp.Labeler) if !ok { diff --git a/net/http/roundtripware/logger.go b/net/http/roundtripware/logger.go index 83b587a6..fc469c46 100644 --- a/net/http/roundtripware/logger.go +++ b/net/http/roundtripware/logger.go @@ -12,16 +12,26 @@ import ( ) type ( + // LoggerOptions configures the [Logger] RoundTripware. LoggerOptions struct { - Message string + // Message is the log message for completed requests. + Message string + // ErrorMessage is the log message for requests that failed with an + // error. ErrorMessage string - MinWarnCode int + // MinWarnCode is the status code from which requests are logged at + // warn level. Zero disables it. + MinWarnCode int + // MinErrorCode is the status code from which requests are logged at + // error level. Zero disables it. MinErrorCode int } + // LoggerOption configures [LoggerOptions]. LoggerOption func(*LoggerOptions) ) -// GetDefaultLoggerOptions returns the default options +// GetDefaultLoggerOptions returns the default options: warn from 400 and +// error from 500. func GetDefaultLoggerOptions() LoggerOptions { return LoggerOptions{ Message: "sent request", @@ -31,35 +41,40 @@ func GetDefaultLoggerOptions() LoggerOptions { } } -// LoggerWithMessage middleware option +// LoggerWithMessage sets the log message. Defaults to "sent request". func LoggerWithMessage(v string) LoggerOption { return func(o *LoggerOptions) { o.Message = v } } -// LoggerWithErrorMessage middleware option +// LoggerWithErrorMessage sets the log message for failed requests. Defaults +// to "failed to sent request". func LoggerWithErrorMessage(v string) LoggerOption { return func(o *LoggerOptions) { o.ErrorMessage = v } } -// LoggerWithMinWarnCode middleware option +// LoggerWithMinWarnCode sets the status code from which requests are logged +// at warn level. Defaults to 400. func LoggerWithMinWarnCode(v int) LoggerOption { return func(o *LoggerOptions) { o.MinWarnCode = v } } -// LoggerWithMinErrorCode middleware option +// LoggerWithMinErrorCode sets the status code from which requests are logged +// at error level. Defaults to 500. func LoggerWithMinErrorCode(v int) LoggerOption { return func(o *LoggerOptions) { o.MinErrorCode = v } } -// Logger returns a RoundTripware which logs all requests +// Logger returns a RoundTripware that logs every outgoing request with its +// duration and, on success, status code and response size. The level depends +// on the error and status code. func Logger(opts ...LoggerOption) RoundTripware { o := GetDefaultLoggerOptions() diff --git a/net/http/roundtripware/metric.go b/net/http/roundtripware/metric.go index 80e04411..dcad4406 100644 --- a/net/http/roundtripware/metric.go +++ b/net/http/roundtripware/metric.go @@ -10,7 +10,11 @@ import ( "go.uber.org/zap" ) -// Deprecated: use keelhttp.WithTelemetry instead. +// Metric returns a RoundTripware that records the request duration in +// seconds in a Float64Histogram named name. Failed requests are not recorded. +// It panics if the histogram cannot be created. +// +// Deprecated: Use keelhttp.WithTelemetry instead. func Metric(meter metric.Meter, name, description string) RoundTripware { histogram, err := meter.Float64Histogram( name, diff --git a/net/http/roundtripware/recover.go b/net/http/roundtripware/recover.go index 418cb4cb..639925df 100644 --- a/net/http/roundtripware/recover.go +++ b/net/http/roundtripware/recover.go @@ -11,27 +11,33 @@ import ( ) type ( + // RecoverOptions configures the [Recover] RoundTripware. RecoverOptions struct { + // DisablePrintStack reports whether the stack trace is omitted from + // the log entry. DisablePrintStack bool } + // RecoverOption configures [RecoverOptions]. RecoverOption func(options *RecoverOptions) ) -// GetDefaultRecoverOptions returns the default options +// GetDefaultRecoverOptions returns the default options, which include the +// stack trace. func GetDefaultRecoverOptions() RecoverOptions { return RecoverOptions{ DisablePrintStack: false, } } -// RecoverWithDisablePrintStack roundTripware option +// RecoverWithDisablePrintStack sets [RecoverOptions.DisablePrintStack]. func RecoverWithDisablePrintStack(v bool) RecoverOption { return func(o *RecoverOptions) { o.DisablePrintStack = v } } -// Recover returns a RoundTripper which catches any panics +// Recover returns a RoundTripware that recovers and logs panics from the next +// handler. A recovered request returns a nil response and a nil error. func Recover(opts ...RecoverOption) RoundTripware { options := GetDefaultRecoverOptions() @@ -44,7 +50,7 @@ func Recover(opts ...RecoverOption) RoundTripware { return RecoverWithOptions(options) } -// RecoverWithOptions returns a RoundTripper which catches any panics +// RecoverWithOptions is like [Recover] but takes fully populated options. func RecoverWithOptions(opts RecoverOptions) RoundTripware { return func(l *zap.Logger, next Handler) Handler { return func(r *http.Request) (*http.Response, error) { diff --git a/net/http/roundtripware/referer.go b/net/http/roundtripware/referer.go index 8b47ff0e..922d699a 100644 --- a/net/http/roundtripware/referer.go +++ b/net/http/roundtripware/referer.go @@ -12,27 +12,32 @@ import ( ) type ( + // RefererOptions configures the [Referer] RoundTripware. RefererOptions struct { + // Header is the name of the request header to set. Header string } + // RefererOption configures [RefererOptions]. RefererOption func(*RefererOptions) ) -// GetDefaultRefererOptions returns the default options +// GetDefaultRefererOptions returns the default options using the X-Referer +// header. func GetDefaultRefererOptions() RefererOptions { return RefererOptions{ Header: "X-Referer", } } -// RefererWithHeader middleware option +// RefererWithHeader sets the request header name. Defaults to X-Referer. func RefererWithHeader(v string) RefererOption { return func(o *RefererOptions) { o.Header = v } } -// Referer returns a RoundTripper which prints out the request & response object +// Referer returns a RoundTripware that sets the referer stored in the request +// context as request header, unless the header is already present. func Referer(opts ...RefererOption) RoundTripware { o := GetDefaultRefererOptions() diff --git a/net/http/roundtripware/requestid.go b/net/http/roundtripware/requestid.go index d23f6ea1..51a7a5cf 100644 --- a/net/http/roundtripware/requestid.go +++ b/net/http/roundtripware/requestid.go @@ -13,15 +13,21 @@ import ( ) type ( + // RequestIDOptions configures the [RequestID] RoundTripware. RequestIDOptions struct { - Header string - Provider provider.RequestID + // Header is the name of the request header to set. + Header string + // Provider generates a request ID when the context holds none. + Provider provider.RequestID + // SetHeader is not used. SetHeader bool } + // RequestIDOption configures [RequestIDOptions]. RequestIDOption func(*RequestIDOptions) ) -// GetDefaultRequestIDOptions returns the default options +// GetDefaultRequestIDOptions returns the default options using the +// X-Request-ID header and [provider.DefaultRequestID]. func GetDefaultRequestIDOptions() RequestIDOptions { return RequestIDOptions{ Header: "X-Request-ID", @@ -29,21 +35,23 @@ func GetDefaultRequestIDOptions() RequestIDOptions { } } -// RequestIDWithHeader middleware option +// RequestIDWithHeader sets the request header name. Defaults to X-Request-ID. func RequestIDWithHeader(v string) RequestIDOption { return func(o *RequestIDOptions) { o.Header = v } } -// RequestIDWithProvider middleware option +// RequestIDWithProvider sets the request ID provider. Defaults to +// [provider.DefaultRequestID]. func RequestIDWithProvider(v provider.RequestID) RequestIDOption { return func(o *RequestIDOptions) { o.Provider = v } } -// RequestID returns a RoundTripper which prints out the request & response object +// RequestID returns a RoundTripware that sets a request ID header, unless +// already present, using the ID from the request context or a generated one. func RequestID(opts ...RequestIDOption) RoundTripware { o := GetDefaultRequestIDOptions() diff --git a/net/http/roundtripware/retry.go b/net/http/roundtripware/retry.go index c9bf36a6..748ab4ef 100644 --- a/net/http/roundtripware/retry.go +++ b/net/http/roundtripware/retry.go @@ -13,14 +13,20 @@ import ( ) type ( + // RetryOptions configures the [Retry] RoundTripware. RetryOptions struct { + // Handler classifies a response; a non-nil error triggers a retry. Handler RetryHandler retryOptions []retry.Option } + // RetryHandler returns a non-nil error if the response should be retried. RetryHandler func(*http.Response) error - RetryOption func(*RetryOptions) + // RetryOption configures [RetryOptions]. + RetryOption func(*RetryOptions) ) +// GetDefaultRetryOptions returns the default options, which retry every +// response whose status is not 200 OK using the retry-go defaults. func GetDefaultRetryOptions() RetryOptions { return RetryOptions{ Handler: func(resp *http.Response) error { @@ -33,55 +39,65 @@ func GetDefaultRetryOptions() RetryOptions { } } +// RetryWithAttempts applies [retry.Attempts]. func RetryWithAttempts(v uint) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.Attempts(v)) } } +// RetryWithContext applies [retry.Context]. func RetryWithContext(ctx context.Context) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.Context(ctx)) } } +// RetryWithDelay applies [retry.Delay]. func RetryWithDelay(delay time.Duration) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.Delay(delay)) } } +// RetryWithMaxDelay applies [retry.MaxDelay]. func RetryWithMaxDelay(maxDelay time.Duration) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.MaxDelay(maxDelay)) } } +// RetryWithDelayType applies [retry.DelayType]. func RetryWithDelayType(delayType retry.DelayTypeFunc) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.DelayType(delayType)) } } +// RetryWithOnRetry applies [retry.OnRetry]. func RetryWithOnRetry(onRetry retry.OnRetryFunc) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.OnRetry(onRetry)) } } +// RetryWithLastErrorOnly applies [retry.LastErrorOnly]. func RetryWithLastErrorOnly(lastErrorOnly bool) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.LastErrorOnly(lastErrorOnly)) } } +// RetryWithRetryIf applies [retry.RetryIf]. func RetryWithRetryIf(retryIf retry.RetryIfFunc) RetryOption { return func(o *RetryOptions) { o.retryOptions = append(o.retryOptions, retry.RetryIf(retryIf)) } } -// Retry returns a RoundTripper which retries failed requests +// Retry returns a RoundTripware that retries requests failing with an error +// or rejected by the [RetryHandler]. It returns the last response and the +// retry-go error. Request bodies are not rewound between attempts. func Retry(opts ...RetryOption) RoundTripware { o := GetDefaultRetryOptions() diff --git a/net/http/roundtripware/roundtripper.go b/net/http/roundtripware/roundtripper.go index 0c23c8ff..8eaa31a4 100644 --- a/net/http/roundtripware/roundtripper.go +++ b/net/http/roundtripware/roundtripper.go @@ -4,8 +4,10 @@ import ( "net/http" ) +// RoundTripperFunc adapts a [Handler] to [http.RoundTripper]. type RoundTripperFunc Handler +// RoundTrip calls fn(r). func (fn RoundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return fn(r) } diff --git a/net/http/roundtripware/roundtripware.go b/net/http/roundtripware/roundtripware.go index 56aa344f..c4c72ab2 100644 --- a/net/http/roundtripware/roundtripware.go +++ b/net/http/roundtripware/roundtripware.go @@ -7,15 +7,23 @@ import ( ) type ( - Handler func(r *http.Request) (*http.Response, error) + // Handler sends a request and returns its response, like + // [http.RoundTripper.RoundTrip]. + Handler func(r *http.Request) (*http.Response, error) + // RoundTripware wraps next with additional client-side request handling. RoundTripware func(l *zap.Logger, next Handler) Handler ) +// RoundTripper is an [http.RoundTripper] that runs requests through a chain +// of RoundTripwares before passing them to the parent RoundTripper. type RoundTripper struct { http.RoundTripper handler Handler } +// NewRoundTripper returns a RoundTripper that wraps parent with +// roundTripwares. They are applied in order, so the last one is the outermost +// and sees a request first. func NewRoundTripper(l *zap.Logger, parent http.RoundTripper, roundTripwares ...RoundTripware) *RoundTripper { next := parent.RoundTrip for _, roundTripware := range roundTripwares { @@ -28,6 +36,7 @@ func NewRoundTripper(l *zap.Logger, parent http.RoundTripper, roundTripwares ... } } +// RoundTrip sends req through the RoundTripware chain. func (rt *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return rt.handler(req) } diff --git a/net/http/roundtripware/sessionid.go b/net/http/roundtripware/sessionid.go index 34bb0aeb..e52e1a48 100644 --- a/net/http/roundtripware/sessionid.go +++ b/net/http/roundtripware/sessionid.go @@ -11,28 +11,35 @@ import ( ) type ( + // SessionIDOptions configures the [SessionID] RoundTripware. SessionIDOptions struct { + // Header is the name of the request header to set. Header string } - SessionIDOption func(*SessionIDOptions) + // SessionIDOption configures [SessionIDOptions]. + SessionIDOption func(*SessionIDOptions) + // SessionIDGenerator returns a new session ID. It is not used by this + // package. SessionIDGenerator func() string ) -// GetDefaultSessionIDOptions returns the default options +// GetDefaultSessionIDOptions returns the default options using the +// X-Session-ID header. func GetDefaultSessionIDOptions() SessionIDOptions { return SessionIDOptions{ Header: "X-Session-ID", } } -// SessionIDWithHeader middleware option +// SessionIDWithHeader sets the request header name. Defaults to X-Session-ID. func SessionIDWithHeader(v string) SessionIDOption { return func(o *SessionIDOptions) { o.Header = v } } -// SessionID returns a RoundTripper which prints out the request & response object +// SessionID returns a RoundTripware that sets the session ID stored in the +// request context as request header, unless the header is already present. func SessionID(opts ...SessionIDOption) RoundTripware { o := GetDefaultSessionIDOptions() diff --git a/net/http/roundtripware/trackingid.go b/net/http/roundtripware/trackingid.go index b65fba83..9f053dc4 100644 --- a/net/http/roundtripware/trackingid.go +++ b/net/http/roundtripware/trackingid.go @@ -12,28 +12,36 @@ import ( ) type ( + // TrackingIDOptions configures the [TrackingID] RoundTripware. TrackingIDOptions struct { + // Header is the name of the request header to set. Header string } - TrackingIDOption func(*TrackingIDOptions) + // TrackingIDOption configures [TrackingIDOptions]. + TrackingIDOption func(*TrackingIDOptions) + // TrackingIDGenerator returns a new tracking ID. It is not used by this + // package. TrackingIDGenerator func() string ) -// GetDefaultTrackingIDOptions returns the default options +// GetDefaultTrackingIDOptions returns the default options using the +// X-Tracking-ID header. func GetDefaultTrackingIDOptions() TrackingIDOptions { return TrackingIDOptions{ Header: "X-Tracking-ID", } } -// TrackingIDWithHeader middleware option +// TrackingIDWithHeader sets the request header name. Defaults to +// X-Tracking-ID. func TrackingIDWithHeader(v string) TrackingIDOption { return func(o *TrackingIDOptions) { o.Header = v } } -// TrackingID returns a RoundTripper which prints out the request & response object +// TrackingID returns a RoundTripware that sets the tracking ID stored in the +// request context as request header, unless the header is already present. func TrackingID(opts ...TrackingIDOption) RoundTripware { o := GetDefaultTrackingIDOptions() diff --git a/net/http/server.go b/net/http/server.go index 69d96539..b4ee1d28 100644 --- a/net/http/server.go +++ b/net/http/server.go @@ -8,7 +8,14 @@ import ( "go.uber.org/zap/zapcore" ) -// NewServer creates and configures an HTTP server for in-cluster traffic. +// NewServer creates and configures an HTTP server for in-cluster traffic +// listening on addr. handler is wrapped with middlewares using [Compose], and +// net/http errors are logged through l at error level. +// +// The server speaks HTTP/1.1 only and sets conservative timeouts: +// ReadHeaderTimeout 5s, ReadTimeout 30s, WriteTimeout 60s, IdleTimeout 620s +// and MaxHeaderBytes 1 MiB. Streaming or large-upload handlers must clear the +// read or write deadline via [http.NewResponseController]. func NewServer(l *zap.Logger, name, addr string, handler http.Handler, middlewares ...Middleware) *http.Server { // net/http's own errors belong at error level errLog, err := zap.NewStdLogAt(l.Named("http.server"), zapcore.ErrorLevel) diff --git a/net/stream/doc.go b/net/stream/doc.go new file mode 100644 index 00000000..e87b131f --- /dev/null +++ b/net/stream/doc.go @@ -0,0 +1,5 @@ +// Package stream provides shared types for NATS based messaging. +// +// Transport specific implementations live in subpackages such as +// [github.com/foomo/keel/net/stream/jetstream]. +package stream diff --git a/net/stream/jetstream/doc.go b/net/stream/jetstream/doc.go new file mode 100644 index 00000000..e20841f5 --- /dev/null +++ b/net/stream/jetstream/doc.go @@ -0,0 +1,23 @@ +// Package jetstream provides a thin wrapper around NATS JetStream with +// structured logging, reconnect handling and typed publishers and subscribers. +// +// A [Stream] holds the NATS connection and JetStream context. Use +// [Stream.Publisher] and [Stream.Subscriber] to create subject bound +// publishers and subscribers that marshal and unmarshal message payloads +// (JSON by default): +// +// s, err := jetstream.New(l, "orders", nats.DefaultURL, +// jetstream.WithNamespace("shop"), +// jetstream.WithConfig(&nats.StreamConfig{Subjects: []string{"shop.>"}}), +// ) +// if err != nil { +// return err +// } +// defer s.Close() +// +// pub := s.Publisher("created") +// _, err = pub.PublishMsg(order) +// +// All publishers and subscribers created through a [Stream] are recorded +// and listed by [Readme]. +package jetstream diff --git a/net/stream/jetstream/publisher.go b/net/stream/jetstream/publisher.go index 4ac1d7e1..2603d2ae 100644 --- a/net/stream/jetstream/publisher.go +++ b/net/stream/jetstream/publisher.go @@ -5,6 +5,8 @@ import ( ) type ( + // Publisher publishes marshaled payloads to a single subject of a + // [Stream]. Create one with [Stream.Publisher]. Publisher struct { stream *Stream subject string @@ -13,13 +15,17 @@ type ( marshal MarshalFn header nats.Header } + // MarshalFn encodes a payload into message data. MarshalFn func(v any) ([]byte, error) ) +// JS returns the JetStream context of the underlying [Stream]. func (s *Publisher) JS() nats.JetStreamContext { return s.stream.js } +// Subject returns the subject messages are published to, prefixed with +// the namespace and a dot if a namespace is set. func (s *Publisher) Subject() string { if s.namespace != "" { return s.namespace + "." + s.subject @@ -28,6 +34,8 @@ func (s *Publisher) Subject() string { return s.subject } +// NewMsg marshals v and returns a message addressed to [Publisher.Subject] +// carrying the publisher's header. It returns the marshal error, if any. func (s *Publisher) NewMsg(v any) (*nats.Msg, error) { data, err := s.Marshal(v) if err != nil { @@ -43,10 +51,13 @@ func (s *Publisher) NewMsg(v any) (*nats.Msg, error) { return msg, nil } +// PubOpts returns the publisher's default publish options followed by opts. func (s *Publisher) PubOpts(opts ...nats.PubOpt) []nats.PubOpt { return append(s.pubOpts, opts...) } +// PublishMsg marshals data and publishes it synchronously, waiting for the +// server acknowledgement. opts are appended to the publisher's defaults. func (s *Publisher) PublishMsg(data any, opts ...nats.PubOpt) (*nats.PubAck, error) { if msg, err := s.NewMsg(data); err != nil { return nil, err @@ -55,6 +66,9 @@ func (s *Publisher) PublishMsg(data any, opts ...nats.PubOpt) (*nats.PubAck, err } } +// PublishMsgAsync marshals data and publishes it without waiting for the +// acknowledgement, which is delivered through the returned future. opts are +// appended to the publisher's defaults. func (s *Publisher) PublishMsgAsync(data any, opts ...nats.PubOpt) (nats.PubAckFuture, error) { if msg, err := s.NewMsg(data); err != nil { return nil, err @@ -63,6 +77,7 @@ func (s *Publisher) PublishMsgAsync(data any, opts ...nats.PubOpt) (nats.PubAckF } } +// Marshal encodes v with the publisher's [MarshalFn]. func (s *Publisher) Marshal(v any) ([]byte, error) { return s.marshal(v) } diff --git a/net/stream/jetstream/readme.go b/net/stream/jetstream/readme.go index 1c2ab5a8..132ef3a9 100644 --- a/net/stream/jetstream/readme.go +++ b/net/stream/jetstream/readme.go @@ -5,11 +5,15 @@ import ( ) type ( + // publisher records a publisher created through [Stream.Publisher] for + // [Readme]. publisher struct { Namespace string Stream string Subject string } + // subscriber records a subscriber created through [Stream.Subscriber] + // for [Readme]. subscriber struct { Namespace string Stream string @@ -17,11 +21,15 @@ type ( } ) +// publishers and subscribers hold the process wide, deduplicated registry +// read by [Readme]. Access is not synchronized. var ( publishers []publisher subscribers []subscriber ) +// Readme returns a markdown table of all publishers and subscribers created +// in this process, or an empty string if there are none. func Readme() string { if len(publishers) == 0 && len(subscribers) == 0 { return "" diff --git a/net/stream/jetstream/stream.go b/net/stream/jetstream/stream.go index 497d6083..5510baad 100644 --- a/net/stream/jetstream/stream.go +++ b/net/stream/jetstream/stream.go @@ -14,6 +14,8 @@ import ( ) type ( + // Stream wraps a NATS connection and its JetStream context for a single + // named stream. Create one with [New]. Stream struct { l *zap.Logger addr string @@ -30,39 +32,49 @@ type ( reconnectTimeout time.Duration reconnectFailedHandler func(error) } - Option func(*Stream) - PublisherOption func(*Publisher) + // Option configures a [Stream]. + Option func(*Stream) + // PublisherOption configures a [Publisher]. + PublisherOption func(*Publisher) + // SubscriberOption configures a [Subscriber]. SubscriberOption func(*Subscriber) ) -// WithNamespace option +// WithNamespace sets the namespace prefixed to the subjects of all +// publishers and subscribers created by the stream. func WithNamespace(v string) Option { return func(o *Stream) { o.namespace = v } } -// WithReconnectFailedHandler option +// WithReconnectFailedHandler sets the function called with the last error +// when all reconnect attempts failed. Defaults to a function that panics. func WithReconnectFailedHandler(v func(error)) Option { return func(o *Stream) { o.reconnectFailedHandler = v } } -// WithReconnectTimeout option +// WithReconnectTimeout sets the delay between reconnect attempts after a +// disconnect error. Defaults to 15 seconds. func WithReconnectTimeout(v time.Duration) Option { return func(o *Stream) { o.reconnectTimeout = v } } -// WithReconnectMaxRetries option +// WithReconnectMaxRetries sets the number of reconnect attempts after a +// disconnect error. Defaults to 10. func WithReconnectMaxRetries(v int) Option { return func(o *Stream) { o.reconnectMaxRetries = v } } +// WithConfig sets the stream configuration used to create or update the +// stream on connect. The config name is overwritten with the stream name. +// opts are passed to the stream management calls. func WithConfig(v *nats.StreamConfig, opts ...nats.JSOpt) Option { return func(o *Stream) { o.config = v @@ -70,56 +82,69 @@ func WithConfig(v *nats.StreamConfig, opts ...nats.JSOpt) Option { } } -// WithJSOptions option +// WithJSOptions appends options used when creating the JetStream context. func WithJSOptions(v ...nats.JSOpt) Option { return func(o *Stream) { o.jsOptions = append(o.jsOptions, v...) } } -// WithNatsOptions option +// WithNatsOptions appends options used when connecting to NATS. They are +// applied after, and may override, the stream's default handlers. func WithNatsOptions(v ...nats.Option) Option { return func(o *Stream) { o.natsOptions = append(o.natsOptions, v...) } } +// PublisherWithPubOpts appends default publish options of the publisher. func PublisherWithPubOpts(v ...nats.PubOpt) PublisherOption { return func(o *Publisher) { o.pubOpts = append(o.pubOpts, v...) } } +// PublisherWithMarshal sets the payload encoder. Defaults to [json.Marshal]. func PublisherWithMarshal(marshal MarshalFn) PublisherOption { return func(o *Publisher) { o.marshal = marshal } } +// PublisherWithHeader sets the header attached to every published message. func PublisherWithHeader(v nats.Header) PublisherOption { return func(o *Publisher) { o.header = v } } +// SubscriberWithNamespace overrides the stream's namespace for the +// subscriber's subject. func SubscriberWithNamespace(v string) SubscriberOption { return func(o *Subscriber) { o.namespace = v } } +// SubscriberWithSubOpts appends default subscribe options of the subscriber. func SubscriberWithSubOpts(v ...nats.SubOpt) SubscriberOption { return func(o *Subscriber) { o.opts = append(o.opts, v...) } } +// SubscriberWithUnmarshal sets the payload decoder. Defaults to +// [json.Unmarshal]. func SubscriberWithUnmarshal(unmarshal UnmarshalFn) SubscriberOption { return func(o *Subscriber) { o.unmarshal = unmarshal } } +// New connects to the NATS server at addr and returns a [Stream] named +// name. If a config is set via [WithConfig], the stream is created or +// updated. On a disconnect error the stream reconnects according to the +// reconnect options. It returns an error if the initial connect fails. func New(l *zap.Logger, name, addr string, opts ...Option) (*Stream, error) { stream := &Stream{ l: l.With( @@ -154,26 +179,34 @@ func New(l *zap.Logger, name, addr string, opts ...Option) (*Stream, error) { return stream, nil } +// Addr returns the NATS server address. func (s *Stream) Addr() string { return s.addr } +// JS returns the JetStream context. func (s *Stream) JS() nats.JetStreamContext { return s.js } +// Conn returns the NATS connection. func (s *Stream) Conn() *nats.Conn { return s.conn } +// Name returns the stream name. func (s *Stream) Name() string { return s.name } +// Info returns the stream info obtained when creating or updating the +// stream, or nil if no config was set. func (s *Stream) Info() *nats.StreamInfo { return s.info } +// Publisher returns a [Publisher] for subject using the stream's namespace +// and JSON encoding unless overridden by opts. It is recorded for [Readme]. func (s *Stream) Publisher(subject string, opts ...PublisherOption) *Publisher { pub := &Publisher{ stream: s, @@ -204,6 +237,8 @@ func (s *Stream) Publisher(subject string, opts ...PublisherOption) *Publisher { return pub } +// Subscriber returns a [Subscriber] for subject using the stream's namespace +// and JSON decoding unless overridden by opts. It is recorded for [Readme]. func (s *Stream) Subscriber(subject string, opts ...SubscriberOption) *Subscriber { sub := &Subscriber{ stream: s, @@ -234,10 +269,13 @@ func (s *Stream) Subscriber(subject string, opts ...SubscriberOption) *Subscribe return sub } +// Close closes the NATS connection. func (s *Stream) Close() { s.conn.Close() } +// connect establishes the NATS connection and JetStream context and creates +// or updates the stream if a config is set. func (s *Stream) connect() error { // connect nats conn, err := nats.Connect(s.addr, s.natsOptions...) @@ -292,6 +330,8 @@ func (s *Stream) connect() error { return nil } +// initNatsOptions prepends the default logging, reconnect and timeout +// options to the user supplied NATS options. func (s *Stream) initNatsOptions() { natsOpts := append([]nats.Option{ nats.ErrorHandler(func(conn *nats.Conn, subscription *nats.Subscription, err error) { diff --git a/net/stream/jetstream/subscriber.go b/net/stream/jetstream/subscriber.go index 1ac11f4c..28ed550b 100644 --- a/net/stream/jetstream/subscriber.go +++ b/net/stream/jetstream/subscriber.go @@ -10,6 +10,8 @@ import ( ) type ( + // Subscriber subscribes to a single subject of a [Stream]. Create one with + // [Stream.Subscriber]. Subscriber struct { stream *Stream subject string @@ -17,13 +19,17 @@ type ( unmarshal UnmarshalFn opts []nats.SubOpt } + // UnmarshalFn decodes message data into v. UnmarshalFn func(data []byte, v any) error ) +// JS returns the JetStream context of the underlying [Stream]. func (s *Subscriber) JS() nats.JetStreamContext { return s.stream.js } +// Subject returns the subscribed subject, prefixed with the namespace and a +// dot if a namespace is set. func (s *Subscriber) Subject() string { if s.namespace != "" { return s.namespace + "." + s.subject @@ -32,10 +38,15 @@ func (s *Subscriber) Subject() string { return s.subject } +// SubOpts returns the subscriber's default subscribe options followed by +// opts. func (s *Subscriber) SubOpts(opts ...nats.SubOpt) []nats.SubOpt { return append(s.opts, opts...) } +// Subscribe creates an asynchronous subscription that calls handler for each +// message with a background context and the stream's logger. Handler errors +// are logged. opts are appended to the subscriber's defaults. func (s *Subscriber) Subscribe(handler stream.MsgHandler, opts ...nats.SubOpt) (*nats.Subscription, error) { return s.JS().Subscribe(s.Subject(), func(msg *nats.Msg) { ctx := context.Background() @@ -45,10 +56,14 @@ func (s *Subscriber) Subscribe(handler stream.MsgHandler, opts ...nats.SubOpt) ( }, s.SubOpts(opts...)...) } +// ChanSubscribe creates a subscription that delivers messages to ch. Only +// opts are used; the subscriber's default options are not applied. func (s *Subscriber) ChanSubscribe(ch chan *nats.Msg, opts ...nats.SubOpt) (*nats.Subscription, error) { return s.JS().ChanSubscribe(s.Subject(), ch, opts...) } +// QueueSubscribe is like [Subscriber.Subscribe] but joins the queue group +// queue, so each message is delivered to only one member of the group. func (s *Subscriber) QueueSubscribe(queue string, handler stream.MsgHandler, opts ...nats.SubOpt) (*nats.Subscription, error) { return s.JS().QueueSubscribe(s.Subject(), queue, func(msg *nats.Msg) { ctx := context.Background() @@ -58,10 +73,13 @@ func (s *Subscriber) QueueSubscribe(queue string, handler stream.MsgHandler, opt }, s.SubOpts(opts...)...) } +// Unmarshal decodes the data of msg into v with the subscriber's +// [UnmarshalFn]. func (s *Subscriber) Unmarshal(msg *nats.Msg, v any) error { return s.unmarshal(msg.Data, v) } +// errorHandler logs a handler error. func (s *Subscriber) errorHandler(err error) { s.stream.l.Error("failed to handle message", log.FError(err)) } diff --git a/net/stream/middleware.go b/net/stream/middleware.go index 797043e3..ccc61d7b 100644 --- a/net/stream/middleware.go +++ b/net/stream/middleware.go @@ -7,4 +7,7 @@ import ( "go.uber.org/zap" ) +// MsgHandler handles a received NATS message. The logger is scoped to the +// stream the message was received on. A returned error is logged by the +// caller; it does not nak or redeliver the message. type MsgHandler func(context.Context, *zap.Logger, *nats.Msg) error diff --git a/option.go b/option.go index ce6c7d81..aaf7269f 100644 --- a/option.go +++ b/option.go @@ -16,31 +16,33 @@ import ( "github.com/foomo/keel/telemetry" ) -// Option func +// Option configures a [Server] in [NewServer]. type Option func(inst *Server) -// WithLogger option +// WithLogger sets the server logger. Defaults to [log.Logger]. func WithLogger(l *zap.Logger) Option { return func(inst *Server) { inst.l = l } } -// WithLogFields option +// WithLogFields adds fields to the server logger. func WithLogFields(fields ...zap.Field) Option { return func(inst *Server) { inst.l = inst.l.With(fields...) } } -// WithConfig option +// WithConfig sets the server configuration. Defaults to [config.Config]. func WithConfig(c *viper.Viper) Option { return func(inst *Server) { inst.c = c } } -// WithRemoteConfig option +// WithRemoteConfig adds a remote configuration source using +// [config.WithRemoteConfig] unless config.remote.enabled is false. Failing to +// add the source is fatal. func WithRemoteConfig(provider, endpoint, path string) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "config.remote.enabled", true)() { @@ -50,28 +52,34 @@ func WithRemoteConfig(provider, endpoint, path string) Option { } } -// WithContext option +// WithContext sets the parent of the server context. Defaults to +// [context.Background]. func WithContext(ctx context.Context) Option { return func(inst *Server) { inst.ctx = ctx } } -// WithShutdownSignals option +// WithShutdownSignals sets the signals that trigger graceful shutdown. Defaults +// to SIGINT and SIGTERM. func WithShutdownSignals(shutdownSignals ...os.Signal) Option { return func(inst *Server) { inst.shutdownSignals = shutdownSignals } } -// WithGracefulPeriod option +// WithGracefulPeriod sets the time budget for closing resources on graceful +// shutdown. It should match the pod's terminationGracePeriodSeconds. Defaults +// to KEEL_GRACEFUL_PERIOD seconds, or 30 seconds. func WithGracefulPeriod(gracefulPeriod time.Duration) Option { return func(inst *Server) { inst.gracefulPeriod = gracefulPeriod } } -// WithHTTPZapService option with default value +// WithHTTPZapService registers the log level service from +// [service.NewDefaultHTTPZap] as an init service. enabled is the default for the +// service.zap.enabled config key. func WithHTTPZapService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.zap.enabled", enabled)() { @@ -82,7 +90,9 @@ func WithHTTPZapService(enabled bool) Option { } } -// WithHTTPViperService option with default value +// WithHTTPViperService registers the configuration service from +// [service.NewDefaultHTTPViper] as an init service. enabled is the default for +// the service.viper.enabled config key. func WithHTTPViperService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.viper.enabled", enabled)() { @@ -93,7 +103,7 @@ func WithHTTPViperService(enabled bool) Option { } } -// WithTelemetry option wires the OpenTelemetry trace, metric and logger providers +// WithTelemetry wires the OpenTelemetry trace, metric and logger providers // from the standard OTEL environment variables: // // OTEL_TRACES_EXPORTER none(default) | console | otlp @@ -103,7 +113,7 @@ func WithHTTPViperService(enabled bool) Option { // (per-signal override: OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL) // // A signal set to none leaves its provider unset, so the server falls back to a -// no-op provider. Call this before WithPushgatewayMeter so its nil meter-provider +// no-op provider. Call this before [WithPushgatewayMeter] so its nil meter-provider // guard still fires when OTEL_METRICS_EXPORTER is none. func WithTelemetry() Option { return func(inst *Server) { @@ -130,7 +140,8 @@ func WithTelemetry() Option { } } -// WithStdOutTracer option with default value +// WithStdOutTracer sets a trace provider that writes to stdout. enabled is the +// default for the otel.enabled config key. func WithStdOutTracer(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -142,7 +153,8 @@ func WithStdOutTracer(enabled bool) Option { } } -// WithStdOutLogger option with default value +// WithStdOutLogger sets a logger provider that writes to stdout. enabled is the +// default for the otel.enabled config key. func WithStdOutLogger(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -154,7 +166,8 @@ func WithStdOutLogger(enabled bool) Option { } } -// WithOTLPHTTPLogger option with default value +// WithOTLPHTTPLogger sets an OTLP HTTP logger provider. enabled is the default +// for the otel.enabled config key. func WithOTLPHTTPLogger(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -166,7 +179,8 @@ func WithOTLPHTTPLogger(enabled bool) Option { } } -// WithOTLPGRCPLogger option with default value +// WithOTLPGRCPLogger sets an OTLP gRPC logger provider. enabled is the default +// for the otel.enabled config key. func WithOTLPGRCPLogger(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -178,7 +192,8 @@ func WithOTLPGRCPLogger(enabled bool) Option { } } -// WithStdOutMeter option with default value +// WithStdOutMeter sets a meter provider that writes to stdout. enabled is the +// default for the otel.enabled config key. func WithStdOutMeter(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -190,7 +205,8 @@ func WithStdOutMeter(enabled bool) Option { } } -// WithOTLPGRPCTracer option with default value +// WithOTLPGRPCTracer sets an OTLP gRPC trace provider. enabled is the default +// for the otel.enabled config key. func WithOTLPGRPCTracer(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -202,7 +218,8 @@ func WithOTLPGRPCTracer(enabled bool) Option { } } -// WithOTLPHTTPTracer option with default value +// WithOTLPHTTPTracer sets an OTLP HTTP trace provider. enabled is the default +// for the otel.enabled config key. func WithOTLPHTTPTracer(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -214,8 +231,8 @@ func WithOTLPHTTPTracer(enabled bool) Option { } } -// WithOTLPGRPCMeter option with default value Metrics are pushed via OTLP gRPC -// via a periodic reader, suiting setups that export metrics instead of exposing a +// WithOTLPGRPCMeter sets an OTLP gRPC meter provider. enabled is the default for +// the otel.enabled config key. Metrics are pushed by a periodic reader, suiting setups that export metrics instead of exposing a // Prometheus scrape endpoint. func WithOTLPGRPCMeter(enabled bool) Option { return func(inst *Server) { @@ -228,8 +245,8 @@ func WithOTLPGRPCMeter(enabled bool) Option { } } -// WithOTLPHTTPMeter option with default value Metrics are pushed via OTLP HTTP -// via a periodic reader, suiting setups that export metrics instead of exposing a +// WithOTLPHTTPMeter sets an OTLP HTTP meter provider. enabled is the default for +// the otel.enabled config key. Metrics are pushed by a periodic reader, suiting setups that export metrics instead of exposing a // Prometheus scrape endpoint. func WithOTLPHTTPMeter(enabled bool) Option { return func(inst *Server) { @@ -242,7 +259,8 @@ func WithOTLPHTTPMeter(enabled bool) Option { } } -// WithPrometheusMeter option with default value +// WithPrometheusMeter sets a Prometheus meter provider. enabled is the default +// for the otel.enabled config key. func WithPrometheusMeter(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -254,10 +272,10 @@ func WithPrometheusMeter(enabled bool) Option { } } -// WithPushgatewayMeter option pushes Prometheus metrics to a Pushgateway on an -// interval and once more on graceful shutdown. An empty url disables it; the url -// falls back to the service.pushgateway.url config/env value. The push interval -// falls back to service.pushgateway.interval (default 15s). It sets up a Prometheus +// WithPushgatewayMeter registers an init service that pushes Prometheus metrics +// to a Pushgateway on an interval and once more on graceful shutdown. url is the +// default for the service.pushgateway.url config key; an empty result disables +// it. The push interval is read from service.pushgateway.interval (default 15s). It sets up a Prometheus // meter provider (unless one is already configured) so OTEL metrics are included in // the push. func WithPushgatewayMeter(url string) Option { @@ -308,7 +326,9 @@ func WithPushgatewayMeter(url string) Option { } } -// WithPyroscopeService option with default value +// WithPyroscopeService registers an init service running the Pyroscope profiler +// from [telemetry.NewProfiler]. enabled is the default for the otel.enabled +// config key. func WithPyroscopeService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "otel.enabled", enabled)() { @@ -330,7 +350,9 @@ func WithPyroscopeService(enabled bool) Option { } } -// WithHTTPPrometheusService option with default value +// WithHTTPPrometheusService registers the metrics service from +// [service.NewDefaultHTTPPrometheus] as an init service. enabled is the default +// for the service.prometheus.enabled config key. func WithHTTPPrometheusService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.prometheus.enabled", enabled)() { @@ -341,7 +363,9 @@ func WithHTTPPrometheusService(enabled bool) Option { } } -// WithHTTPPProfService option with default value +// WithHTTPPProfService registers the pprof service from +// [service.NewDefaultHTTPPProf] as an init service. enabled is the default for +// the service.pprof.enabled config key. func WithHTTPPProfService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.pprof.enabled", enabled)() { @@ -352,7 +376,9 @@ func WithHTTPPProfService(enabled bool) Option { } } -// WithHTTPHealthzService option with default value +// WithHTTPHealthzService registers the health probe service from +// [service.NewDefaultHTTPProbes] as an init service. enabled is the default for +// the service.healthz.enabled config key. func WithHTTPHealthzService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.healthz.enabled", enabled)() { @@ -363,9 +389,11 @@ func WithHTTPHealthzService(enabled bool) Option { } } -// WithHTTPReadmeService option with default value +// WithHTTPReadmeService registers the readme service from +// [service.NewDefaultHTTPReadme] as an init service. enabled is the default for +// the service.readme.enabled config key. // -// Deprecated: will be removed in future releases +// Deprecated: Readme support will be removed in a future release. func WithHTTPReadmeService(enabled bool) Option { return func(inst *Server) { if config.GetBool(inst.Config(), "service.readme.enabled", enabled)() { @@ -376,7 +404,8 @@ func WithHTTPReadmeService(enabled bool) Option { } } -// WithInitService option with a default value +// WithInitService registers service as an init service, started in [NewServer]. +// Nil and already registered services are ignored. func WithInitService(service Service) Option { return func(inst *Server) { if service == nil || slices.Contains(inst.initServices, service) { diff --git a/persistence/distributedrwlock.go b/persistence/distributedrwlock.go index c75c7d9c..331a7659 100644 --- a/persistence/distributedrwlock.go +++ b/persistence/distributedrwlock.go @@ -4,11 +4,15 @@ import ( "context" ) -// DistributedRWLock coordinates uploads (shared) against the sync -// (exclusive) on one cluster-wide resource. Implemented by -// DistributedRWLockMongo; faked in tests. Acquire* return (false, nil) -// when blocked by the other side -// (no error). +// DistributedRWLock coordinates shared and exclusive holders of a single +// cluster-wide resource. Any number of shared holders may hold the lock +// concurrently, while an exclusive holder excludes all others. +// +// AcquireShared and AcquireExclusive return (true, nil) on success and +// (false, nil) when blocked by the other side; a non-nil error indicates a +// backend failure. Release frees the lock held under lockID. +// +// See github.com/foomo/keel/persistence/mongo for a MongoDB implementation. type DistributedRWLock interface { AcquireShared(ctx context.Context, lockID string) (bool, error) AcquireExclusive(ctx context.Context, lockID string) (bool, error) diff --git a/persistence/doc.go b/persistence/doc.go new file mode 100644 index 00000000..066e792f --- /dev/null +++ b/persistence/doc.go @@ -0,0 +1,8 @@ +// Package keelpersistence provides backend-agnostic persistence contracts +// shared by the keel storage integrations: the sentinel errors [ErrNotFound] +// and [ErrDirtyWrite], and the [DistributedRWLock] interface. +// +// Concrete implementations live in separate modules, such as +// github.com/foomo/keel/persistence/mongo and +// github.com/foomo/keel/persistence/postgres. +package keelpersistence diff --git a/persistence/error.go b/persistence/error.go index b186400b..ae7040e7 100644 --- a/persistence/error.go +++ b/persistence/error.go @@ -5,6 +5,9 @@ import ( ) var ( - ErrNotFound = errors.New("not found error") + // ErrNotFound is returned when a requested entity does not exist. + ErrNotFound = errors.New("not found error") + // ErrDirtyWrite is returned when an optimistic, version-checked write + // fails because the stored entity was modified concurrently. ErrDirtyWrite = errors.New("dirty write error") ) diff --git a/persistence/mongo/collection.go b/persistence/mongo/collection.go index d1d691fd..8a99cef6 100644 --- a/persistence/mongo/collection.go +++ b/persistence/mongo/collection.go @@ -17,7 +17,7 @@ import ( ) // dbs and indices form a package-level registry of every collection and -// named index ever created via NewCollection. It exists purely so Readme() +// named index ever created via [NewCollection]. It exists purely so [Readme] // can render the docs table — nothing in the runtime path reads it. // registryMu guards both maps: NewCollection writes them and Readme reads // them, potentially from different goroutines. @@ -28,22 +28,31 @@ var ( ) type ( - DecodeFn func(val any) error + // DecodeFn decodes the current document into val. + DecodeFn func(val any) error + // IterateHandlerFn is called once per document by the iterating + // methods; returning an error stops the iteration and is returned to + // the caller. IterateHandlerFn func(decode DecodeFn) error ) -// Collection can only be used in the Persistor.WithCollection call.ss type ( + // Collection wraps a [mongo.Collection] whose documents are keyed by an + // "id" field. Create it with [NewCollection] or [Persistor.Collection]. Collection struct { db *mongo.Database collection *mongo.Collection } + // CollectionOptions configures [NewCollection]. CollectionOptions struct { *options.CollectionOptionsBuilder *options.CreateIndexesOptionsBuilder - Indexes []mongo.IndexModel + // Indexes are created when the collection is set up. + Indexes []mongo.IndexModel + // IndexesMaxTime bounds index creation; zero means no timeout. IndexesMaxTime time.Duration } + // CollectionOption configures [CollectionOptions]. CollectionOption func(*CollectionOptions) ) @@ -51,6 +60,8 @@ type ( // ~ Options // ------------------------------------------------------------------------------------------------ +// DefaultCollectionOptions returns empty collection and index creation +// options. func DefaultCollectionOptions() CollectionOptions { return CollectionOptions{ CollectionOptionsBuilder: options.Collection(), @@ -58,60 +69,76 @@ func DefaultCollectionOptions() CollectionOptions { } } +// CollectionWithReadConcern sets the collection's read concern. func CollectionWithReadConcern(v *readconcern.ReadConcern) CollectionOption { return func(o *CollectionOptions) { o.SetReadConcern(v) } } +// CollectionWithWriteConcern sets the collection's write concern. func CollectionWithWriteConcern(v *writeconcern.WriteConcern) CollectionOption { return func(o *CollectionOptions) { o.SetWriteConcern(v) } } +// CollectionWithReadPreference sets the collection's read preference. func CollectionWithReadPreference(v *readpref.ReadPref) CollectionOption { return func(o *CollectionOptions) { o.SetReadPreference(v) } } +// CollectionWithRegistry sets the BSON registry used by the collection. func CollectionWithRegistry(v *bson.Registry) CollectionOption { return func(o *CollectionOptions) { o.SetRegistry(v) } } +// CollectionWithIndexes sets the indexes created by [NewCollection], +// replacing any previously set. func CollectionWithIndexes(v ...mongo.IndexModel) CollectionOption { return func(o *CollectionOptions) { o.Indexes = v } } +// CollectionWithIndexesMaxTime sets the timeout for index creation. +// Defaults to no timeout. func CollectionWithIndexesMaxTime(v time.Duration) CollectionOption { return func(o *CollectionOptions) { o.IndexesMaxTime = v } } +// CollectionWithCommitQuorumInt sets the index build commit quorum to the +// given number of data-bearing voting members. func CollectionWithCommitQuorumInt(v int32) CollectionOption { return func(o *CollectionOptions) { o.SetCommitQuorumInt(v) } } +// CollectionWithCommitQuorumMajority sets the index build commit quorum to +// "majority". func CollectionWithCommitQuorumMajority() CollectionOption { return func(o *CollectionOptions) { o.SetCommitQuorumMajority() } } +// CollectionWithCommitQuorumString sets the index build commit quorum to the +// given string, e.g. a replica set tag name. func CollectionWithCommitQuorumString(v string) CollectionOption { return func(o *CollectionOptions) { o.SetCommitQuorumString(v) } } +// CollectionWithCommitQuorumVotingMembers sets the index build commit quorum +// to "votingMembers". The context argument is unused. func CollectionWithCommitQuorumVotingMembers(v context.Context) CollectionOption { return func(o *CollectionOptions) { o.SetCommitQuorumVotingMembers() @@ -122,6 +149,9 @@ func CollectionWithCommitQuorumVotingMembers(v context.Context) CollectionOption // ~ Constructor // ------------------------------------------------------------------------------------------------ +// NewCollection returns a [Collection] for name in db, registers it for +// [Readme] and synchronously creates any configured indexes. It returns an +// error if index creation fails. It is safe for concurrent use. func NewCollection(db *mongo.Database, name string, opts ...CollectionOption) (*Collection, error) { o := DefaultCollectionOptions() for _, opt := range opts { @@ -185,10 +215,12 @@ func NewCollection(db *mongo.Database, name string, opts ...CollectionOption) (* // ~ Getter // ------------------------------------------------------------------------------------------------ +// DB returns the database the collection belongs to. func (c *Collection) DB() *mongo.Database { return c.db } +// Col returns the underlying [mongo.Collection]. func (c *Collection) Col() *mongo.Collection { return c.collection } @@ -197,6 +229,8 @@ func (c *Collection) Col() *mongo.Collection { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Get decodes the document with the given id into result. It returns +// [keelpersistence.ErrNotFound] if id is empty or no document matches. func (c *Collection) Get(ctx context.Context, id string, result any, opts ...options.Lister[options.FindOneOptions]) error { if id == "" { return keelpersistence.ErrNotFound @@ -205,6 +239,8 @@ func (c *Collection) Get(ctx context.Context, id string, result any, opts ...opt return c.FindOne(ctx, bson.M{"id": id}, result, opts...) } +// Exists reports whether a document with the given id exists. An empty id +// reports false. func (c *Collection) Exists(ctx context.Context, id string) (bool, error) { if id == "" { return false, nil @@ -215,6 +251,12 @@ func (c *Collection) Exists(ctx context.Context, id string) (bool, error) { return ret > 0, err } +// Upsert stores entity under id. Timestamps are maintained for +// [EntityWithTimestamps]. For [EntityWithVersion] the version is incremented; +// a version-zero entity is inserted via [Collection.Insert], otherwise the +// update only matches the previous version and fails with +// [keelpersistence.ErrDirtyWrite] if none matches. Other entities are +// upserted by id. It returns an error if id is empty or entity is nil. func (c *Collection) Upsert(ctx context.Context, id string, entity Entity) error { if id == "" { return errors.New("id must not be empty") @@ -261,7 +303,12 @@ func (c *Collection) Upsert(ctx context.Context, id string, entity Entity) error return nil } -// UpsertMany - NOTE: upsert many does NOT through an explicit error on dirty write so we can only assume it. +// UpsertMany stores entities in a single unordered bulk write, applying the +// same timestamp and version rules as [Collection.Upsert] using each +// entity's ID. The bulk write does not report dirty writes per document, so +// [keelpersistence.ErrDirtyWrite] is returned when fewer versioned documents +// matched or were modified than expected. It returns an error if any entity +// is nil or has an empty ID. func (c *Collection) UpsertMany(ctx context.Context, entities []Entity) error { var ( versionUpserts int64 @@ -331,6 +378,9 @@ func (c *Collection) UpsertMany(ctx context.Context, entities []Entity) error { return nil } +// Insert inserts entity, setting timestamps for [EntityWithTimestamps] and +// incrementing the version for [EntityWithVersion]. It returns an error if +// entity is nil or has an empty ID. func (c *Collection) Insert(ctx context.Context, entity Entity) error { if entity == nil { return errors.New("entity must not be nil") @@ -359,6 +409,8 @@ func (c *Collection) Insert(ctx context.Context, entity Entity) error { return nil } +// InsertMany inserts entities, applying the same rules as [Collection.Insert] +// to each. It returns an error if any entity is nil or has an empty ID. func (c *Collection) InsertMany(ctx context.Context, entities []Entity) error { inserts := make([]any, len(entities)) for i, entity := range entities { @@ -392,6 +444,8 @@ func (c *Collection) InsertMany(ctx context.Context, entities []Entity) error { return nil } +// Delete removes the document with the given id. It returns +// [keelpersistence.ErrNotFound] if id is empty or no document matches. func (c *Collection) Delete(ctx context.Context, id string) error { if id == "" { return keelpersistence.ErrNotFound @@ -406,6 +460,8 @@ func (c *Collection) Delete(ctx context.Context, id string) error { return nil } +// Find decodes all documents matching filter into results, which must be a +// pointer to a slice. func (c *Collection) Find(ctx context.Context, filter, results any, opts ...options.Lister[options.FindOptions]) error { cursor, err := c.collection.Find(ctx, filter, opts...) if errors.Is(err, mongo.ErrNoDocuments) { @@ -421,6 +477,8 @@ func (c *Collection) Find(ctx context.Context, filter, results any, opts ...opti return cursor.Err() } +// FindOne decodes the first document matching filter into result. It returns +// [keelpersistence.ErrNotFound] if no document matches. func (c *Collection) FindOne(ctx context.Context, filter, result any, opts ...options.Lister[options.FindOneOptions]) error { res := c.collection.FindOne(ctx, filter, opts...) if errors.Is(res.Err(), mongo.ErrNoDocuments) { @@ -432,6 +490,8 @@ func (c *Collection) FindOne(ctx context.Context, filter, result any, opts ...op return res.Decode(result) } +// FindIterate calls handler for each document matching filter, stopping at +// the first handler error. func (c *Collection) FindIterate(ctx context.Context, filter any, handler IterateHandlerFn, opts ...options.Lister[options.FindOptions]) error { cursor, err := c.collection.Find(ctx, filter, opts...) if errors.Is(err, mongo.ErrNoDocuments) { @@ -451,6 +511,8 @@ func (c *Collection) FindIterate(ctx context.Context, filter any, handler Iterat return cursor.Err() } +// Aggregate runs pipeline and decodes all resulting documents into results, +// which must be a pointer to a slice. func (c *Collection) Aggregate(ctx context.Context, pipeline mongo.Pipeline, results any, opts ...options.Lister[options.AggregateOptions]) error { cursor, err := c.collection.Aggregate(ctx, pipeline, opts...) if err != nil { @@ -464,6 +526,8 @@ func (c *Collection) Aggregate(ctx context.Context, pipeline mongo.Pipeline, res return cursor.Err() } +// AggregateIterate runs pipeline and calls handler for each resulting +// document, stopping at the first handler error. func (c *Collection) AggregateIterate(ctx context.Context, pipeline mongo.Pipeline, handler IterateHandlerFn, opts ...options.Lister[options.AggregateOptions]) error { cursor, err := c.collection.Aggregate(ctx, pipeline, opts...) if err != nil { @@ -481,12 +545,12 @@ func (c *Collection) AggregateIterate(ctx context.Context, pipeline mongo.Pipeli return cursor.Err() } -// Count returns the count of documents +// Count returns the number of documents matching filter. func (c *Collection) Count(ctx context.Context, filter any, opts ...options.Lister[options.CountOptions]) (int64, error) { return c.collection.CountDocuments(ctx, filter, opts...) } -// CountAll returns the count of all documents +// CountAll returns the number of documents in the collection. func (c *Collection) CountAll(ctx context.Context) (int64, error) { return c.Count(ctx, bson.D{}) } diff --git a/persistence/mongo/distributedrwlock.go b/persistence/mongo/distributedrwlock.go index c78790c7..a6c999c4 100644 --- a/persistence/mongo/distributedrwlock.go +++ b/persistence/mongo/distributedrwlock.go @@ -9,7 +9,9 @@ import ( mongolock "github.com/foomo/mongo-lock" ) -// DistributedRWLock acquires shared and exclusive locks on one configured resource. +// DistributedRWLock implements [keelpersistence.DistributedRWLock] on a +// MongoDB collection, acquiring shared and exclusive locks on one configured +// resource. type DistributedRWLock struct { client *mongolock.Client resourceName string @@ -19,8 +21,10 @@ type DistributedRWLock struct { drainTimeout time.Duration } -// NewDistributedRWLock builds the lock on the configured collection of persistor and ensures its -// indexes exist. +// NewDistributedRWLock builds the lock on the configured collection of +// persistor. It returns an error if cfg lacks CollectionName or ResourceName. +// The lock's indexes are not created; call +// [DistributedRWLock.CreateIndexes] to ensure they exist. func NewDistributedRWLock(persistor *Persistor, cfg DistributedRWLockConfig) (*DistributedRWLock, error) { cfg, err := cfg.normalized() if err != nil { @@ -42,6 +46,7 @@ func NewDistributedRWLock(persistor *Persistor, cfg DistributedRWLockConfig) (*D }, nil } +// CreateIndexes creates the indexes required by the lock collection. func (l *DistributedRWLock) CreateIndexes(ctx context.Context) error { return l.client.CreateIndexes(ctx) } @@ -62,9 +67,10 @@ func (l *DistributedRWLock) AcquireShared(ctx context.Context, lockID string) (b } } -// AcquireExclusive takes the exclusive lock. It retries for up to DrainTimeout to -// let in-flight shared locks drain, then returns (false, nil) if still blocked so -// the caller skips this round. +// AcquireExclusive takes the exclusive lock. It retries every DrainWait for up +// to DrainTimeout to let in-flight shared locks drain, then returns +// (false, nil) if still blocked so the caller skips this round. It returns +// ctx.Err() if ctx is done while waiting. func (l *DistributedRWLock) AcquireExclusive(ctx context.Context, lockID string) (bool, error) { deadline := time.Now().Add(l.drainTimeout) diff --git a/persistence/mongo/doc.go b/persistence/mongo/doc.go new file mode 100644 index 00000000..d96cd352 --- /dev/null +++ b/persistence/mongo/doc.go @@ -0,0 +1,29 @@ +// Package keelmongo provides a MongoDB persistor for keel services built on +// the official go.mongodb.org/mongo-driver/v2 driver, with optional +// OpenTelemetry instrumentation. +// +// # Persistor and collections +// +// [New] connects to the database named in the connection URI. Collections +// are obtained via [Persistor.Collection], which can also ensure indexes: +// +// p, err := keelmongo.New(ctx, "mongodb://localhost:27017/mydb") +// col, err := p.Collection("users", keelmongo.CollectionWithIndexes(...)) +// err = col.Get(ctx, "id-1", &user) +// +// [Collection] stores documents keyed by an "id" field. Entities implementing +// [EntityWithTimestamps] get their timestamps maintained on write, and +// entities implementing [EntityWithVersion] get optimistic locking: +// conflicting writes fail with [keelpersistence.ErrDirtyWrite]. Lookups of +// missing documents fail with [keelpersistence.ErrNotFound]. +// +// # Distributed lock +// +// [DistributedRWLock] implements [keelpersistence.DistributedRWLock] on a +// dedicated collection using github.com/foomo/mongo-lock. +// +// # Documentation +// +// [Readme] renders a Markdown table of all collections and named indexes +// created through this package. +package keelmongo diff --git a/persistence/mongo/entity.go b/persistence/mongo/entity.go index 08e2fc3e..06c8e936 100644 --- a/persistence/mongo/entity.go +++ b/persistence/mongo/entity.go @@ -2,16 +2,22 @@ package keelmongo import "time" +// Entity is a document identified by a string ID stored in the "id" field. type Entity interface { SetID(id string) GetID() string } +// EntityWithVersion is implemented by entities that use optimistic locking. +// The version is stored in the "version" field and IncreaseVersion +// increments it before each write. type EntityWithVersion interface { GetVersion() uint32 IncreaseVersion() uint32 } +// EntityWithTimestamps is implemented by entities whose creation and update +// times are maintained on write. The creation time is only set if zero. type EntityWithTimestamps interface { SetCreatedAt(t time.Time) GetCreatedAt() time.Time diff --git a/persistence/mongo/persistor.go b/persistence/mongo/persistor.go index d82eb441..c95d6f63 100644 --- a/persistence/mongo/persistor.go +++ b/persistence/mongo/persistor.go @@ -17,47 +17,66 @@ import ( "go.uber.org/zap" ) -// Persistor exported to used also for embedding into other types in foreign packages. type ( + // Persistor holds a MongoDB client and the database named in its + // connection URI. It is exported so it can be embedded into types in + // other packages. Persistor struct { client *mongo.Client db *mongo.Database } + // Options configures [New]. Options struct { - OtelEnabled bool - OtelOptions []otelmongo.Option - ClientOptions []ClientOption + // OtelEnabled enables the OpenTelemetry command monitor. + OtelEnabled bool + // OtelOptions configure the OpenTelemetry command monitor. + OtelOptions []otelmongo.Option + // ClientOptions are applied to the client options after the URI. + ClientOptions []ClientOption + // ClientLoggerOptions configure the driver logger. They are only + // applied if no logger options were set via ClientOptions. ClientLoggerOptions []ClientLoggerOption - DatabaseOptions []DatabaseOption - } - Option func(o *Options) - ClientOption func(*options.ClientOptions) + // DatabaseOptions are applied to the database options. + DatabaseOptions []DatabaseOption + } + // Option configures [Options]. + Option func(o *Options) + // ClientOption configures the driver's client options. + ClientOption func(*options.ClientOptions) + // ClientLoggerOption configures the driver's logger options. ClientLoggerOption func(*options.LoggerOptions) - DatabaseOption func(*options.DatabaseOptionsBuilder) + // DatabaseOption configures the driver's database options. + DatabaseOption func(*options.DatabaseOptionsBuilder) ) // ------------------------------------------------------------------------------------------------ // ~ Options // ------------------------------------------------------------------------------------------------ +// WithOtelEnabled sets whether OpenTelemetry instrumentation is enabled. +// Defaults to the OTEL_MONGO_ENABLED environment variable, falling back to +// OTEL_ENABLED, or false. func WithOtelEnabled(v bool) Option { return func(o *Options) { o.OtelEnabled = v } } +// WithOtelOptions appends options for the OpenTelemetry command monitor. func WithOtelOptions(v ...otelmongo.Option) Option { return func(o *Options) { o.OtelOptions = append(o.OtelOptions, v...) } } +// WithClientOptions appends client options. func WithClientOptions(v ...ClientOption) Option { return func(o *Options) { o.ClientOptions = append(o.ClientOptions, v...) } } +// WithClientLogger routes the driver's log output to the given zap logger. func WithClientLogger(v *zap.Logger) Option { return func(o *Options) { o.ClientLoggerOptions = append(o.ClientLoggerOptions, func(o *options.LoggerOptions) { @@ -66,6 +85,7 @@ func WithClientLogger(v *zap.Logger) Option { } } +// WithClientLoggerComponentLevel sets the driver log level for component c. func WithClientLoggerComponentLevel(c options.LogComponent, l options.LogLevel) Option { return func(o *Options) { o.ClientLoggerOptions = append(o.ClientLoggerOptions, func(o *options.LoggerOptions) { @@ -74,18 +94,24 @@ func WithClientLoggerComponentLevel(c options.LogComponent, l options.LogLevel) } } +// WithClientCompression is an [Option] enabling snappy and zstd wire +// compression. func WithClientCompression(o *Options) { o.ClientOptions = append(o.ClientOptions, func(o *options.ClientOptions) { o.SetCompressors([]string{"snappy", "zstd"}) }) } +// WithDatabaseOptions appends database options. func WithDatabaseOptions(v ...DatabaseOption) Option { return func(o *Options) { o.DatabaseOptions = append(o.DatabaseOptions, v...) } } +// DefaultOptions returns the default [Options]: majority read and write +// concern, OpenTelemetry enabled per OTEL_MONGO_ENABLED or OTEL_ENABLED, and +// command attributes disabled per OTEL_MONGO_COMMAND_ATTRIBUTE_DISABLED. func DefaultOptions() Options { return Options{ OtelEnabled: env.GetBool("OTEL_MONGO_ENABLED", env.GetBool("OTEL_ENABLED", false)), @@ -107,6 +133,9 @@ func DefaultOptions() Options { // ~ Constructor // ------------------------------------------------------------------------------------------------ +// New connects to MongoDB using uri, pings the server and returns a +// [Persistor] for the database named in uri. It returns an error if uri is +// invalid, names no database, or the connection or ping fails. func New(ctx context.Context, uri string, opts ...Option) (*Persistor, error) { o := DefaultOptions() @@ -166,23 +195,27 @@ func New(ctx context.Context, uri string, opts ...Option) (*Persistor, error) { }, nil } +// DB returns the database. func (p Persistor) DB() *mongo.Database { return p.db } +// Client returns the underlying [mongo.Client]. func (p Persistor) Client() *mongo.Client { return p.client } +// Ping verifies the connection to the server. func (p Persistor) Ping(ctx context.Context) error { return p.client.Ping(ctx, nil) } +// Collection returns the named [Collection] via [NewCollection]. func (p Persistor) Collection(name string, opts ...CollectionOption) (*Collection, error) { return NewCollection(p.db, name, opts...) } -// HasCollection checks if the given collection exists +// HasCollection reports whether a collection with the given name exists. func (p Persistor) HasCollection(ctx context.Context, name string) (bool, error) { names, err := p.db.ListCollectionNames(ctx, bson.D{}) if err != nil { @@ -196,6 +229,7 @@ func (p Persistor) HasCollection(ctx context.Context, name string) (bool, error) return false, nil } +// Close disconnects the client. func (p Persistor) Close(ctx context.Context) error { return p.client.Disconnect(ctx) } diff --git a/persistence/mongo/readme.go b/persistence/mongo/readme.go index 1b05a997..c15cb9b4 100644 --- a/persistence/mongo/readme.go +++ b/persistence/mongo/readme.go @@ -6,6 +6,9 @@ import ( "github.com/foomo/keel/markdown" ) +// Readme returns a Markdown section listing every database, collection and +// named index registered through [NewCollection]. It returns an empty string +// if none are registered. It is safe for concurrent use. func Readme() string { var rows [][]string diff --git a/persistence/mongo/utils.go b/persistence/mongo/utils.go index 30aefeb2..bcc5e5c3 100644 --- a/persistence/mongo/utils.go +++ b/persistence/mongo/utils.go @@ -7,7 +7,8 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) -// CloseCursor with defer +// CloseCursor closes cursor and logs any error. It is intended for use with +// defer. func CloseCursor(ctx context.Context, cursor *mongo.Cursor) { if err := cursor.Close(ctx); err != nil { log.WithError(nil, err).Error("failed to close cursor") diff --git a/persistence/postgres/doc.go b/persistence/postgres/doc.go new file mode 100644 index 00000000..e051d8e3 --- /dev/null +++ b/persistence/postgres/doc.go @@ -0,0 +1,10 @@ +// Package keelpostgres provides a thin PostgreSQL persistor built on +// [database/sql] and the github.com/lib/pq driver. +// +// [New] opens and pings the database and optionally runs an initialization +// statement: +// +// p, err := keelpostgres.New(ctx, "postgres://user:pass@localhost:5432/db", +// keelpostgres.WithInit("create table if not exists ..."), +// ) +package keelpostgres diff --git a/persistence/postgres/persistor.go b/persistence/postgres/persistor.go index 6a329e21..02649be0 100644 --- a/persistence/postgres/persistor.go +++ b/persistence/postgres/persistor.go @@ -11,37 +11,51 @@ import ( "github.com/foomo/keel/log" ) -// Persistor exported to used also for embedding into other types in foreign packages. type ( + // Persistor wraps a [sql.DB] connected to PostgreSQL. It is exported so it + // can be embedded into types in other packages. Its methods are safe for + // concurrent use as far as the underlying [sql.DB] is. Persistor struct { db *sql.DB l *zap.Logger } + // Options configures [New]. Options struct { - Init string + // Init is an optional SQL statement executed once after connecting. + Init string + // Logger is the logger stored on the [Persistor]. Logger *zap.Logger } + // Option configures [Options]. Option func(*Options) ) +// WithInit sets an SQL statement that [New] executes after connecting. +// Defaults to none. func WithInit(v string) Option { return func(o *Options) { o.Init = v } } +// WithLogger sets the logger. Defaults to [log.Logger]. func WithLogger(v *zap.Logger) Option { return func(o *Options) { o.Logger = v } } +// DefaultOptions returns the default [Options] using the global keel logger. func DefaultOptions() Options { return Options{ Logger: log.Logger(), } } +// New connects to the PostgreSQL database identified by the connection +// string dns, verifies the connection with a ping and, if configured via +// [WithInit], executes the init statement. It returns an error if the +// connection string is invalid, the ping fails, or the init statement fails. func New(ctx context.Context, dns string, opts ...Option) (*Persistor, error) { // urlExample := "postgres://username:password@localhost:5432/database_name" o := DefaultOptions() @@ -77,6 +91,8 @@ func New(ctx context.Context, dns string, opts ...Option) (*Persistor, error) { return p, nil } +// TableExists reports whether a table with the given name exists in +// information_schema.tables. func (p *Persistor) TableExists(ctx context.Context, name string) (bool, error) { var n int64 if err := p.db.QueryRowContext(ctx, `select 1 from information_schema.tables where table_name=$1`, name).Scan(&n); errors.Is(err, sql.ErrNoRows) { @@ -88,18 +104,23 @@ func (p *Persistor) TableExists(ctx context.Context, name string) (bool, error) return true, nil } +// Ping verifies the database connection is alive. func (p *Persistor) Ping(ctx context.Context) error { return p.db.PingContext(ctx) } +// DB returns the underlying [sql.DB]. func (p *Persistor) DB() *sql.DB { return p.db } +// Conn returns a single dedicated connection from the pool. The caller must +// close it when done. func (p *Persistor) Conn(ctx context.Context) (*sql.Conn, error) { return p.db.Conn(ctx) } +// Close closes the underlying database. func (p *Persistor) Close() error { return p.db.Close() } diff --git a/runtime.go b/runtime.go index d67a0aa4..92ca0cb0 100644 --- a/runtime.go +++ b/runtime.go @@ -9,8 +9,8 @@ import ( "go.uber.org/zap" ) -// Runtime is the shared accessor surface of Server and Job. Integrations should -// accept a Runtime instead of a concrete *Server so the same instrumented helper +// Runtime is the shared accessor surface of [Server] and [Job]. Integrations should +// accept a Runtime instead of a concrete *[Server] so the same instrumented helper // works under a long-lived Server or a short-lived Job. type Runtime interface { // Logger returns the structured logger. diff --git a/semconv/debug.go b/semconv/debug.go index da2e9e86..38527aa5 100644 --- a/semconv/debug.go +++ b/semconv/debug.go @@ -9,7 +9,7 @@ const ( DebugEnabledKey = attribute.Key("debug.enabled") ) -// DebugEnabled returns a new attribute.KeyValue for keel.service.type. +// DebugEnabled returns a new attribute.KeyValue for debug.enabled. func DebugEnabled(v bool) attribute.KeyValue { return DebugEnabledKey.Bool(v) } diff --git a/semconv/doc.go b/semconv/doc.go new file mode 100644 index 00000000..2bf643cc --- /dev/null +++ b/semconv/doc.go @@ -0,0 +1,10 @@ +// Package semconv provides OpenTelemetry attribute keys and constructors for +// keel specific semantic conventions. +// +// Each attribute is exposed as a +// [go.opentelemetry.io/otel/attribute.Key] constant (for example +// [KeelServiceNameKey]) and a constructor returning a +// [go.opentelemetry.io/otel/attribute.KeyValue] (for example +// [KeelServiceName]). Attributes cover keel services, debug state and gotsrpc +// calls. +package semconv diff --git a/semconv/gotsrpc.go b/semconv/gotsrpc.go index a3e6cba5..3abf8046 100644 --- a/semconv/gotsrpc.go +++ b/semconv/gotsrpc.go @@ -4,6 +4,7 @@ import ( "go.opentelemetry.io/otel/attribute" ) +// Attribute keys for gotsrpc calls. const ( GoTSRPCFuncKey = attribute.Key("gotsrpc.func") GoTSRPCServiceKey = attribute.Key("gotsrpc.service") diff --git a/semconv/gotsrpcconv/doc.go b/semconv/gotsrpcconv/doc.go new file mode 100644 index 00000000..6316d161 --- /dev/null +++ b/semconv/gotsrpcconv/doc.go @@ -0,0 +1,7 @@ +// Package gotsrpcconv provides typed OpenTelemetry metric instruments for +// gotsrpc semantic conventions. +// +// [NewExecutionDuration] creates the "gotsrpc.execution.duration" histogram, +// recorded in seconds and labeled with the gotsrpc package, service and +// function. A nil meter yields a no-op instrument. +package gotsrpcconv diff --git a/semconv/gotsrpcconv/metric.go b/semconv/gotsrpcconv/metric.go index d5289bbf..4750de48 100644 --- a/semconv/gotsrpcconv/metric.go +++ b/semconv/gotsrpcconv/metric.go @@ -9,6 +9,7 @@ import ( "go.opentelemetry.io/otel/metric/noop" ) +// recOptPool pools record option slices to avoid allocations per record. var ( // addOptPool = &sync.Pool{New: func() any { return &[]metric.AddOption{} }} recOptPool = &sync.Pool{New: func() any { return &[]metric.RecordOption{} }} @@ -21,12 +22,16 @@ type ExecutionDuration struct { metric.Float64Histogram } +// newExecutionDurationOpts are the histogram options appended to every +// [NewExecutionDuration] call. var newExecutionDurationOpts = []metric.Float64HistogramOption{ metric.WithDescription("Duration of GOTSRPC execution."), metric.WithUnit("s"), } -// NewExecutionDuration returns a new ExecutionDuration instrument. +// NewExecutionDuration returns a new ExecutionDuration instrument created +// with m and opt. If m is nil, a no-op instrument is returned. On error, the +// returned instrument is a no-op. func NewExecutionDuration( m metric.Meter, opt ...metric.Float64HistogramOption, @@ -63,16 +68,19 @@ func (ExecutionDuration) Name() string { return "gotsrpc.execution.duration" } -// Unit returns the semantic convention unit of the instrument +// Unit returns the semantic convention unit of the instrument. func (ExecutionDuration) Unit() string { return "s" } -// Description returns the semantic convention description of the instrument +// Description returns the semantic convention description of the instrument. func (ExecutionDuration) Description() string { return "Duration of GOTSRPC execution." } +// Record records val, in seconds, labeled with the gotsrpc package pkg, +// service svs, function fnc and attrs. If attrs is empty, val is recorded +// without any attributes, including pkg, svs and fnc. func (m ExecutionDuration) Record( ctx context.Context, val float64, @@ -126,6 +134,8 @@ func (m ExecutionDuration) RecordSet(ctx context.Context, val float64, set attri m.Float64Histogram.Record(ctx, val, *o...) } +// AttrError returns an optional attribute for the "gotsrpc.error" semantic +// convention, reporting whether the call failed. func (ExecutionDuration) AttrError(val bool) attribute.KeyValue { return attribute.Bool("gotsrpc.error", val) } diff --git a/server.go b/server.go index 3e822349..e78aaca0 100644 --- a/server.go +++ b/server.go @@ -37,6 +37,8 @@ import ( "golang.org/x/sync/errgroup" ) +// Names and default addresses of the HTTP services registered by +// [Server.AddInternalHTTPService] and [Server.AddPublicHTTPService]. const ( ServiceNameInternalHTTP = "internal" ServiceAddrInternalHTTP = ":8080" @@ -44,7 +46,9 @@ const ( ServiceAddrPublicHTTP = ":8000" ) -// Server struct +// Server runs a set of [Service] values until a shutdown signal is received and +// then shuts them down gracefully. Create it with [NewServer]; the zero value is +// not usable. Registration methods are safe for concurrent use. type Server struct { services []Service initServices []Service @@ -72,6 +76,11 @@ type Server struct { c *viper.Viper } +// NewServer creates a [Server] configured by opts and immediately starts its +// init services. It sets the global OpenTelemetry logger, error handler and +// text map propagator, falls back to no-op trace, meter and logger providers +// when none are configured, and registers the server itself as an always +// probe and a readiness probe that fails once shutdown has started. func NewServer(opts ...Option) *Server { inst := &Server{ gracefulPeriod: time.Duration(env.GetInt("KEEL_GRACEFUL_PERIOD", 30)) * time.Second, @@ -165,42 +174,44 @@ func NewServer(opts ...Option) *Server { return inst } -// Logger returns server logger +// Logger returns the server logger. func (s *Server) Logger() *zap.Logger { return s.l } -// Meter returns the implementation meter +// Meter returns the global OpenTelemetry meter from [telemetry.Meter]. func (s *Server) Meter() metric.Meter { return telemetry.Meter() } -// Tracer returns the implementation tracer +// Tracer returns the global OpenTelemetry tracer from [telemetry.Tracer]. func (s *Server) Tracer() trace.Tracer { return telemetry.Tracer() } -// Config returns server config +// Config returns the server configuration. func (s *Server) Config() *viper.Viper { return s.c } -// Context returns server context +// Context returns the server context. It is canceled when [Server.Run] returns. func (s *Server) Context() context.Context { return s.ctx } -// ShutdownContext returns server's shutdown cancel context +// ShutdownContext returns a context that is canceled when graceful shutdown is +// triggered by a shutdown signal, [Server.ShutdownCancel] or a failing service. func (s *Server) ShutdownContext() context.Context { return s.gracefulCtx } -// ShutdownCancel returns server's shutdown cancel function +// ShutdownCancel returns a function that triggers graceful shutdown. func (s *Server) ShutdownCancel() context.CancelFunc { return s.gracefulCancel } -// AddService add a single service +// AddService registers v to be started by [Server.Run], as an always probe and +// as a closer. Adding the same service twice is a no-op. func (s *Server) AddService(v Service) { if !slices.Contains(s.services, v) { s.services = append(s.services, v) @@ -209,31 +220,42 @@ func (s *Server) AddService(v Service) { } } +// AddHTTPService registers a [service.HTTP] named name serving handler wrapped +// in middleware. The listen address is read from the config key +// service.http..addr and defaults to addr. func (s *Server) AddHTTPService(name, addr string, handler http.Handler, middleware ...keelhttp.Middleware) { addrFn := config.GetString(s.Config(), "service.http."+name+".addr", addr) s.AddService(service.NewHTTP(s.l, name, addrFn(), handler, middleware...)) } +// AddInternalHTTPService registers an HTTP service named +// [ServiceNameInternalHTTP] listening on [ServiceAddrInternalHTTP] by default. +// See [Server.AddHTTPService]. func (s *Server) AddInternalHTTPService(handler http.Handler, middleware ...keelhttp.Middleware) { s.AddHTTPService(ServiceNameInternalHTTP, ServiceAddrInternalHTTP, handler, middleware...) } +// AddPublicHTTPService registers an HTTP service named [ServiceNamePublicHTTP] +// listening on [ServiceAddrPublicHTTP] by default. See [Server.AddHTTPService]. func (s *Server) AddPublicHTTPService(handler http.Handler, middleware ...keelhttp.Middleware) { s.AddHTTPService(ServiceNamePublicHTTP, ServiceAddrPublicHTTP, handler, middleware...) } +// AddGoRoutine registers a [service.GoRoutine] named name running handler. func (s *Server) AddGoRoutine(name string, handler service.GoRoutineFn, opts ...service.GoRoutineOption) { s.AddService(service.NewGoRoutine(s.l, name, handler, opts...)) } -// AddServices adds multiple service +// AddServices calls [Server.AddService] for each of services. func (s *Server) AddServices(services ...Service) { for _, value := range services { s.AddService(value) } } -// AddCloser adds a closer to be called on shutdown +// AddCloser registers closer to be called on graceful shutdown. Closers are +// called in registration order. A warning is logged if closer does not satisfy +// [IsCloser]; adding the same closer twice is a no-op. func (s *Server) AddCloser(closer any) { if !IsCloser(closer) { s.l.Warn("unable to add closer", log.FValue(fmt.Sprintf("%T", closer))) @@ -246,30 +268,31 @@ func (s *Server) AddCloser(closer any) { s.addClosers(closer) } -// AddClosers adds the given closers to be called on shutdown +// AddClosers calls [Server.AddCloser] for each of closers. func (s *Server) AddClosers(closers ...any) { for _, closer := range closers { s.AddCloser(closer) } } -// AddReadmer adds a readmer to be added to the exposed readme +// AddReadmer adds readmer to the readme exposed by the readme service. // -// Deprecated: will be removed in future releases +// Deprecated: Readme support will be removed in a future release. func (s *Server) AddReadmer(readmer interfaces.Readmer) { s.addReadmers(readmer) } -// AddReadmers adds readmers to be added to the exposed readme +// AddReadmers adds readmers to the readme exposed by the readme service. // -// Deprecated: will be removed in future releases +// Deprecated: Readme support will be removed in a future release. func (s *Server) AddReadmers(readmers ...interfaces.Readmer) { for _, readmer := range readmers { s.AddReadmer(readmer) } } -// AddHealthzer adds a probe to be called on healthz checks +// AddHealthzer registers probe for health checks of type typ. Values that do not +// satisfy [IsHealthz] are ignored. func (s *Server) AddHealthzer(typ healthz.Type, probe any) { if IsHealthz(probe) { s.addProbes(typ, probe) @@ -278,34 +301,34 @@ func (s *Server) AddHealthzer(typ healthz.Type, probe any) { } } -// AddHealthzers adds the given probes to be called on healthz checks +// AddHealthzers calls [Server.AddHealthzer] for each of probes. func (s *Server) AddHealthzers(typ healthz.Type, probes ...any) { for _, probe := range probes { s.AddHealthzer(typ, probe) } } -// AddAlwaysHealthzers adds the probes to be called on any healthz checks +// AddAlwaysHealthzers registers probes that are checked by every probe type. func (s *Server) AddAlwaysHealthzers(probes ...any) { s.AddHealthzers(healthz.TypeAlways, probes...) } -// AddStartupHealthzers adds the startup probes to be called on healthz checks +// AddStartupHealthzers registers probes for the startup check. func (s *Server) AddStartupHealthzers(probes ...any) { s.AddHealthzers(healthz.TypeStartup, probes...) } -// AddLivenessHealthzers adds the liveness probes to be called on healthz checks +// AddLivenessHealthzers registers probes for the liveness check. func (s *Server) AddLivenessHealthzers(probes ...any) { s.AddHealthzers(healthz.TypeLiveness, probes...) } -// AddReadinessHealthzers adds the readiness probes to be called on healthz checks +// AddReadinessHealthzers registers probes for the readiness check. func (s *Server) AddReadinessHealthzers(probes ...any) { s.AddHealthzers(healthz.TypeReadiness, probes...) } -// Healthz returns true if the server is running +// Healthz returns [ErrServerNotRunning] unless [Server.Run] is in progress. func (s *Server) Healthz() error { if !s.running.Load() { return ErrServerNotRunning @@ -314,7 +337,8 @@ func (s *Server) Healthz() error { return nil } -// Readme returns the self-documenting string +// Readme returns a markdown description of the registered services, health +// probes and closers. func (s *Server) Readme() string { md := &markdown.Markdown{} @@ -325,7 +349,9 @@ func (s *Server) Readme() string { return md.String() } -// Run runs the server +// Run starts the registered services and blocks until the server has shut down, +// either gracefully after a shutdown signal or because a service failed. The +// outcome is logged; Run does not exit the process. func (s *Server) Run() { s.l.With(log.Attributes(telemetry.EnvAttributes()...)...).Info("starting keel server") defer s.cancel() @@ -397,7 +423,9 @@ func (s *Server) addProbes(typ healthz.Type, v ...any) { // ~ Private methods // ------------------------------------------------------------------------------------------------ -// startService starts the given services +// startService starts each of services in the server's error group with the +// server context. A service returning [net/http.ErrServerClosed] is treated as +// stopped; any other error fails the group and triggers shutdown. func (s *Server) startService(services ...Service) { done := make(chan struct{}, 1) diff --git a/service.go b/service.go index cdffe7f7..c94e5184 100644 --- a/service.go +++ b/service.go @@ -4,7 +4,11 @@ import ( "context" ) -// Service interface +// Service is a unit of work run by a [Server]. Start is called with the server +// context and is expected to block until the service has stopped; a returned +// error other than [net/http.ErrServerClosed] fails the server. Services +// typically also implement a closer interface (see [IsCloser]) so they are +// stopped on graceful shutdown. type Service interface { Start(ctx context.Context) error } diff --git a/service/doc.go b/service/doc.go new file mode 100644 index 00000000..debe3f17 --- /dev/null +++ b/service/doc.go @@ -0,0 +1,19 @@ +// Package service provides ready made [github.com/foomo/keel.Service] implementations +// for running HTTP servers, goroutines and message subscriptions under a keel +// server. +// +// [HTTP] wraps a [net/http.Server]; [GoRoutine] runs a function in one or more +// goroutines; [Subscription] runs a [goflux.Subscriber]. All of them implement +// Healthz and Close(ctx) error so they double as health probes and closers. +// +// The package also provides constructors for keel's built-in internal HTTP +// services, each with a NewDefaultHTTPXxx variant using the DefaultHTTPXxx +// name, address and path variables: +// +// - [NewHealthz]: Kubernetes health probes (default :9400/healthz) +// - [NewHTTPPrometheus]: Prometheus metrics (default :9200/metrics) +// - [NewHTTPPProf]: pprof profiling (default localhost:6060/debug/pprof) +// - [NewHTTPZap]: runtime log level control (default localhost:9100/log) +// - [NewHTTPViper]: runtime configuration access (default localhost:9300/config) +// - [NewHTTPReadme]: markdown readme (default localhost:9001/readme, deprecated) +package service diff --git a/service/errors.go b/service/errors.go index 97c6c1b2..5167e578 100644 --- a/service/errors.go +++ b/service/errors.go @@ -5,6 +5,10 @@ import ( ) var ( + // ErrServiceNotRunning is returned by the Healthz methods while a service is + // not running. ErrServiceNotRunning = errors.New("service not running") - ErrServiceShutdown = errors.New("service shutdown") + // ErrServiceShutdown is the cancellation cause of a service context canceled + // by Close. + ErrServiceShutdown = errors.New("service shutdown") ) diff --git a/service/goroutine.go b/service/goroutine.go index 63e6c455..b327acc0 100644 --- a/service/goroutine.go +++ b/service/goroutine.go @@ -12,8 +12,10 @@ import ( "golang.org/x/sync/errgroup" ) -// GoRoutine struct type ( + // GoRoutine is a service that runs a [GoRoutineFn] in one or more + // goroutines until it returns or the service is closed. Create it with + // [NewGoRoutine]. GoRoutine struct { running atomic.Bool handler GoRoutineFn @@ -24,10 +26,16 @@ type ( wg errgroup.Group l *zap.Logger } + // GoRoutineOption configures a [GoRoutine] in [NewGoRoutine]. GoRoutineOption func(*GoRoutine) - GoRoutineFn func(ctx context.Context, l *zap.Logger) error + // GoRoutineFn is the function run by a [GoRoutine]. ctx is canceled with + // cause [ErrServiceShutdown] when the service is closed; l is enriched with + // the service name and goroutine instance. + GoRoutineFn func(ctx context.Context, l *zap.Logger) error ) +// NewGoRoutine returns a [GoRoutine] named name that runs handler. A nil l +// defaults to [log.Logger]. By default handler runs in a single goroutine. func NewGoRoutine(l *zap.Logger, name string, handler GoRoutineFn, opts ...GoRoutineOption) *GoRoutine { if l == nil { l = log.Logger() @@ -56,6 +64,8 @@ func NewGoRoutine(l *zap.Logger, name string, handler GoRoutineFn, opts ...GoRou // ~ Options // ------------------------------------------------------------------------------------------------ +// GoRoutineWithParallel sets the number of goroutines running the handler +// concurrently. Defaults to 1. func GoRoutineWithParallel(v int) GoRoutineOption { return func(o *GoRoutine) { o.parallel = v @@ -66,10 +76,12 @@ func GoRoutineWithParallel(v int) GoRoutineOption { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Name returns the service name. func (s *GoRoutine) Name() string { return s.name } +// Healthz returns [ErrServiceNotRunning] unless [GoRoutine.Start] is in progress. func (s *GoRoutine) Healthz() error { if !s.running.Load() { return ErrServiceNotRunning @@ -78,10 +90,13 @@ func (s *GoRoutine) Healthz() error { return nil } +// String returns a short description used in the readme. func (s *GoRoutine) String() string { return fmt.Sprintf("parallel: `%d`", s.parallel) } +// Start runs the handler in the configured number of goroutines and blocks +// until all of them have returned. It returns the first non-nil handler error. func (s *GoRoutine) Start(ctx context.Context) error { s.l.Info("starting keel service") @@ -107,6 +122,9 @@ func (s *GoRoutine) Start(ctx context.Context) error { return s.wg.Wait() } +// Close cancels the handler context with cause [ErrServiceShutdown] and waits +// for all goroutines to return, returning the first non-nil handler error. ctx +// is not used. Close must not be called before [GoRoutine.Start]. func (s *GoRoutine) Close(ctx context.Context) error { s.l.Info("stopping keel service") s.cancelLock.Lock() diff --git a/service/http.go b/service/http.go index 2937e984..23d3e202 100644 --- a/service/http.go +++ b/service/http.go @@ -17,7 +17,7 @@ import ( "github.com/foomo/keel/log" ) -// HTTP struct +// HTTP is a service that serves an [net/http.Server]. Create it with [NewHTTP]. type HTTP struct { l *zap.Logger name string @@ -29,6 +29,9 @@ type HTTP struct { // ~ Constructor // ------------------------------------------------------------------------------------------------ +// NewHTTP returns an [HTTP] service named name listening on addr and serving +// handler wrapped in middlewares. See [keelhttp.NewServer]. A nil l defaults to +// [log.Logger]. func NewHTTP(l *zap.Logger, name, addr string, handler http.Handler, middlewares ...keelhttp.Middleware) *HTTP { if l == nil { l = log.Logger() @@ -50,10 +53,12 @@ func NewHTTP(l *zap.Logger, name, addr string, handler http.Handler, middlewares // ~ Getter // ------------------------------------------------------------------------------------------------ +// Name returns the service name. func (s *HTTP) Name() string { return s.name } +// Server returns the underlying HTTP server. func (s *HTTP) Server() *http.Server { return s.server } @@ -62,6 +67,7 @@ func (s *HTTP) Server() *http.Server { // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Healthz returns [ErrServiceNotRunning] unless the server is listening. func (s *HTTP) Healthz() error { if !s.running.Load() { return ErrServiceNotRunning @@ -70,10 +76,16 @@ func (s *HTTP) Healthz() error { return nil } +// String returns a short description of the handler type and address used in +// the readme. func (s *HTTP) String() string { return fmt.Sprintf("`%T` on `%s`", s.server.Handler, s.server.Addr) } +// Start listens on the configured address and serves requests with ctx as the +// base request context, blocking until the server is shut down. It returns an +// error if the address cannot be bound or serving fails; a graceful shutdown +// returns nil. func (s *HTTP) Start(ctx context.Context) error { var fields []zap.Field @@ -112,6 +124,7 @@ func (s *HTTP) Start(ctx context.Context) error { return nil } +// Close gracefully shuts down the server, bounded by ctx. func (s *HTTP) Close(ctx context.Context) error { s.l.Info("stopping keel service") diff --git a/service/httphealthz.go b/service/httphealthz.go index fc544a5b..6d5fbe2c 100644 --- a/service/httphealthz.go +++ b/service/httphealthz.go @@ -12,20 +12,38 @@ import ( "go.uber.org/zap" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPProbes]. var ( DefaultHTTPHealthzName = "healthz" DefaultHTTPHealthzAddr = ":9400" DefaultHTTPHealthzPath = "/healthz" ) +// Errors reported by the health probe handlers. var ( + // ErrUnhandledHealthzProbe is reported for a probe of an unsupported type. ErrUnhandledHealthzProbe = errors.New("unhandled healthz probe") - ErrProbeFailed = errors.New("probe failed") - ErrLivenessProbeFailed = errors.New("liveness probe failed") - ErrReadinessProbeFailed = errors.New("readiness probe failed") - ErrStartupProbeFailed = errors.New("startup probe failed") + // ErrProbeFailed is reported when a boolean probe fails on the aggregate path. + ErrProbeFailed = errors.New("probe failed") + // ErrLivenessProbeFailed is reported when a boolean liveness probe fails. + ErrLivenessProbeFailed = errors.New("liveness probe failed") + // ErrReadinessProbeFailed is reported when a boolean readiness probe fails. + ErrReadinessProbeFailed = errors.New("readiness probe failed") + // ErrStartupProbeFailed is reported when a boolean startup probe fails. + ErrStartupProbeFailed = errors.New("startup probe failed") ) +// NewHealthz returns an [HTTP] service serving health checks for probes: +// +// - path runs all probes except [healthz.TypeStartup] ones +// - path/liveness runs [healthz.TypeAlways] and [healthz.TypeLiveness] probes +// - path/readiness runs [healthz.TypeAlways] and [healthz.TypeReadiness] probes +// - path/startup runs [healthz.TypeAlways] and [healthz.TypeStartup] probes +// +// Each endpoint responds 200 OK when all probes pass and 503 Service Unavailable +// at the first failing probe. probes is read on every request, so probes added +// to the map later are included. func NewHealthz(l *zap.Logger, name, addr, path string, probes map[healthz.Type][]any) *HTTP { handler := http.NewServeMux() @@ -151,6 +169,8 @@ func NewHealthz(l *zap.Logger, name, addr, path string, probes map[healthz.Type] return NewHTTP(l, name, addr, handler) } +// NewDefaultHTTPProbes returns [NewHealthz] using [DefaultHTTPHealthzName], +// [DefaultHTTPHealthzAddr] and [DefaultHTTPHealthzPath]. func NewDefaultHTTPProbes(l *zap.Logger, probes map[healthz.Type][]any) *HTTP { return NewHealthz( l, diff --git a/service/httppprof.go b/service/httppprof.go index 2594f1b1..8c8401fb 100644 --- a/service/httppprof.go +++ b/service/httppprof.go @@ -8,12 +8,17 @@ import ( "go.uber.org/zap" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPPProf]. var ( DefaultHTTPPProfName = "pprof" DefaultHTTPPProfAddr = "localhost:6060" DefaultHTTPPProfPath = "/debug/pprof" ) +// NewHTTPPProf returns an [HTTP] service exposing the [net/http/pprof] +// handlers below path. The CPU profile endpoint is served by Pyroscope's +// pprof handler. func NewHTTPPProf(l *zap.Logger, name, addr, path string) *HTTP { handler := http.NewServeMux() handler.HandleFunc(path+"/", pprof.Index) @@ -25,6 +30,8 @@ func NewHTTPPProf(l *zap.Logger, name, addr, path string) *HTTP { return NewHTTP(l, name, addr, handler) } +// NewDefaultHTTPPProf returns [NewHTTPPProf] using [DefaultHTTPPProfName], +// [DefaultHTTPPProfAddr] and [DefaultHTTPPProfPath]. func NewDefaultHTTPPProf(l *zap.Logger) *HTTP { return NewHTTPPProf( l, diff --git a/service/httpprometheus.go b/service/httpprometheus.go index 16a5a69e..c7e163f8 100644 --- a/service/httpprometheus.go +++ b/service/httpprometheus.go @@ -8,12 +8,16 @@ import ( "go.uber.org/zap" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPPrometheus]. var ( DefaultHTTPPrometheusName = "prometheus" DefaultHTTPPrometheusAddr = ":9200" DefaultHTTPPrometheusPath = "/metrics" ) +// NewHTTPPrometheus returns an [HTTP] service exposing the metrics of +// [prometheus.DefaultGatherer] on path, with OpenMetrics enabled. func NewHTTPPrometheus(l *zap.Logger, name, addr, path string) *HTTP { handler := http.NewServeMux() handler.Handle(path, promhttp.HandlerFor( @@ -26,6 +30,9 @@ func NewHTTPPrometheus(l *zap.Logger, name, addr, path string) *HTTP { return NewHTTP(l, name, addr, handler) } +// NewDefaultHTTPPrometheus returns [NewHTTPPrometheus] using +// [DefaultHTTPPrometheusName], [DefaultHTTPPrometheusAddr] and +// [DefaultHTTPPrometheusPath]. func NewDefaultHTTPPrometheus(l *zap.Logger) *HTTP { return NewHTTPPrometheus( l, diff --git a/service/httpreadme.go b/service/httpreadme.go index 4444c750..5dc8176d 100644 --- a/service/httpreadme.go +++ b/service/httpreadme.go @@ -8,13 +8,18 @@ import ( "go.uber.org/zap" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPReadme]. var ( DefaultHTTPReadmeName = "readme" DefaultHTTPReadmeAddr = "localhost:9001" DefaultHTTPReadmePath = "/readme" ) -// Deprecated: will be removed in upcoming releases +// NewHTTPReadme returns an [HTTP] service that responds to GET requests on path +// with the concatenated markdown of the readmers returned by readmers. +// +// Deprecated: Readme support will be removed in a future release. func NewHTTPReadme(l *zap.Logger, name, addr, path string, readmers func() []interfaces.Readmer) *HTTP { handler := http.NewServeMux() handler.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { @@ -37,7 +42,10 @@ func NewHTTPReadme(l *zap.Logger, name, addr, path string, readmers func() []int return NewHTTP(l, name, addr, handler) } -// Deprecated: will be removed in upcoming releases +// NewDefaultHTTPReadme returns [NewHTTPReadme] using [DefaultHTTPReadmeName], +// [DefaultHTTPReadmeAddr] and [DefaultHTTPReadmePath]. +// +// Deprecated: Readme support will be removed in a future release. func NewDefaultHTTPReadme(l *zap.Logger, readmers func() []interfaces.Readmer) *HTTP { return NewHTTPReadme( l, diff --git a/service/httpviper.go b/service/httpviper.go index b7bcacd6..33b96fce 100644 --- a/service/httpviper.go +++ b/service/httpviper.go @@ -10,12 +10,17 @@ import ( "github.com/foomo/keel/config" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPViper]. var ( DefaultHTTPViperName = "viper" DefaultHTTPViperAddr = "localhost:9300" DefaultHTTPViperPath = "/config" ) +// NewHTTPViper returns an [HTTP] service exposing c on path. GET responds with +// all settings as JSON; PUT sets a single value from a {"key": ..., "value": ...} +// JSON body. func NewHTTPViper(l *zap.Logger, c *viper.Viper, name, addr, path string) *HTTP { handler := http.NewServeMux() handler.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { @@ -50,6 +55,8 @@ func NewHTTPViper(l *zap.Logger, c *viper.Viper, name, addr, path string) *HTTP return NewHTTP(l, name, addr, handler) } +// NewDefaultHTTPViper returns [NewHTTPViper] for [config.Config] using +// [DefaultHTTPViperName], [DefaultHTTPViperAddr] and [DefaultHTTPViperPath]. func NewDefaultHTTPViper(l *zap.Logger) *HTTP { return NewHTTPViper( l, diff --git a/service/httpzap.go b/service/httpzap.go index 42b57c47..412441c5 100644 --- a/service/httpzap.go +++ b/service/httpzap.go @@ -11,12 +11,18 @@ import ( "github.com/foomo/keel/log" ) +// Default name, address and path of the service returned by +// [NewDefaultHTTPZap]. var ( DefaultHTTPZapName = "zap" DefaultHTTPZapAddr = "localhost:9100" DefaultHTTPZapPath = "/log" ) +// NewHTTPZap returns an [HTTP] service controlling the global logger on path. +// GET responds with the level, disableCaller and disableStacktrace settings as +// JSON; PUT updates any of them from a JSON body of the same shape and responds +// with the resulting settings. func NewHTTPZap(l *zap.Logger, name, addr, path string) *HTTP { handler := http.NewServeMux() handler.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { @@ -108,6 +114,8 @@ func NewHTTPZap(l *zap.Logger, name, addr, path string) *HTTP { return NewHTTP(l, name, addr, handler) } +// NewDefaultHTTPZap returns [NewHTTPZap] using [DefaultHTTPZapName], +// [DefaultHTTPZapAddr] and [DefaultHTTPZapPath]. func NewDefaultHTTPZap(l *zap.Logger) *HTTP { return NewHTTPZap( l, diff --git a/service/subscription.go b/service/subscription.go index c1d5cf6c..406b54cf 100644 --- a/service/subscription.go +++ b/service/subscription.go @@ -14,7 +14,8 @@ import ( ) type ( - // Subscription is a Service that manages the lifecycle of a goflux subscriber. + // Subscription is a service that manages the lifecycle of a goflux subscriber. + // Create it with [NewSubscription]. Subscription[T any] struct { running atomic.Bool subscriber goflux.Subscriber[T] @@ -26,11 +27,12 @@ type ( wg errgroup.Group l *zap.Logger } + // SubscriptionOption configures a [Subscription] in [NewSubscription]. SubscriptionOption[T any] func(*Subscription[T]) ) -// NewSubscription creates a new Subscription service that runs the given -// subscriber as a managed keel service. +// NewSubscription returns a [Subscription] named name that subscribes +// subscriber to subject with handler. A nil l defaults to [log.Logger]. func NewSubscription[T any]( l *zap.Logger, name string, @@ -67,10 +69,13 @@ func NewSubscription[T any]( // ~ Public methods // ------------------------------------------------------------------------------------------------ +// Name returns the service name. func (s *Subscription[T]) Name() string { return s.name } +// Healthz returns [ErrServiceNotRunning] unless [Subscription.Start] is in +// progress. func (s *Subscription[T]) Healthz() error { if !s.running.Load() { return ErrServiceNotRunning @@ -79,10 +84,13 @@ func (s *Subscription[T]) Healthz() error { return nil } +// String returns a short description of the subject used in the readme. func (s *Subscription[T]) String() string { return fmt.Sprintf("subject: `%s`", s.subject) } +// Start subscribes to the subject and blocks until the subscription ends, +// returning the error from the subscriber. func (s *Subscription[T]) Start(ctx context.Context) error { s.l.Info("starting keel service") @@ -107,6 +115,9 @@ func (s *Subscription[T]) Start(ctx context.Context) error { return s.wg.Wait() } +// Close cancels the subscription context with cause [ErrServiceShutdown], +// closes the subscriber (logging a failure) and waits for the subscription to +// end. ctx is not used. Close must not be called before [Subscription.Start]. func (s *Subscription[T]) Close(ctx context.Context) error { s.l.Info("stopping keel service") diff --git a/serviceenabler.go b/serviceenabler.go index 6a546e89..ff975c0d 100644 --- a/serviceenabler.go +++ b/serviceenabler.go @@ -11,6 +11,11 @@ import ( "github.com/foomo/keel/log" ) +// ServiceEnabler is a [Service] that starts and stops a wrapped service at +// runtime depending on the result of an enabled function. The enabled function +// is polled once per second; when it flips to true a fresh service is created +// with the [ServiceFn] and started, when it flips to false the service is +// closed. Failing to start or close a service dynamically is fatal. type ServiceEnabler struct { l *zap.Logger ctx context.Context @@ -24,6 +29,9 @@ type ServiceEnabler struct { syncClosedLock sync.RWMutex } +// NewServiceEnabler returns a [ServiceEnabler] named name that creates its +// service with serviceFn and reads its state from enabledFn. enabledFn is +// called once immediately to determine the initial state. func NewServiceEnabler(l *zap.Logger, name string, serviceFn ServiceFn, enabledFn func() bool) *ServiceEnabler { return &ServiceEnabler{ l: log.WithServiceName(l, name), @@ -34,10 +42,14 @@ func NewServiceEnabler(l *zap.Logger, name string, serviceFn ServiceFn, enabledF } } +// Name returns the name of the service enabler. func (w *ServiceEnabler) Name() string { return w.name } +// Start starts watching the enabled function and starts the wrapped service if +// it is enabled. While the wrapped service is enabled, Start blocks for as long +// as the wrapped service's Start does and returns its error. func (w *ServiceEnabler) Start(ctx context.Context) error { w.ctx = ctx w.watch(w.ctx) //nolint:contextcheck @@ -53,6 +65,9 @@ func (w *ServiceEnabler) Start(ctx context.Context) error { return nil } +// Close stops watching the enabled function and closes the wrapped service if +// it is enabled. The wrapped service is closed with the context passed to +// Start rather than ctx. func (w *ServiceEnabler) Close(ctx context.Context) error { l := log.WithServiceName(w.l, w.Name()) w.setClosed(true) @@ -129,6 +144,8 @@ func (w *ServiceEnabler) disable(ctx context.Context) error { return nil } +// watch polls enabledFn once per second until the enabler is closed and enables +// or disables the wrapped service whenever its result changes. func (w *ServiceEnabler) watch(ctx context.Context) { go func() { //nolint:gosec for { diff --git a/servicefn.go b/servicefn.go index 7db5a7c9..853e483c 100644 --- a/servicefn.go +++ b/servicefn.go @@ -1,3 +1,5 @@ package keel +// ServiceFn creates a [Service]. It is used by [ServiceEnabler] to create a +// fresh service each time it is enabled. type ServiceFn func() Service diff --git a/servicefunc.go b/servicefunc.go index 6d676b06..588e2375 100644 --- a/servicefunc.go +++ b/servicefunc.go @@ -4,8 +4,10 @@ import ( "context" ) +// ServiceFunc adapts a function to the [Service] interface. type ServiceFunc func(ctx context.Context) error +// Start calls fn(ctx). func (fn ServiceFunc) Start(ctx context.Context) error { return fn(ctx) } diff --git a/telemetry/autoconfig.go b/telemetry/autoconfig.go index 273f0db3..9497795c 100644 --- a/telemetry/autoconfig.go +++ b/telemetry/autoconfig.go @@ -12,7 +12,8 @@ import ( "github.com/foomo/keel/env" ) -// Exporter env var values (OpenTelemetry spec). "console" maps to keel's StdOut providers. +// Exporter env var values (OpenTelemetry spec). "console" maps to keel's +// StdOut providers. const ( exporterNone = "none" exporterConsole = "console" @@ -26,9 +27,11 @@ const ( protocolHTTP = "http/protobuf" ) -// otlpProtocol resolves the OTLP protocol for the given signal ("traces", "metrics", "logs"). -// It honours the per-signal override OTEL_EXPORTER_OTLP__PROTOCOL, then the global -// OTEL_EXPORTER_OTLP_PROTOCOL, defaulting to "http/protobuf" per the OpenTelemetry spec. +// otlpProtocol resolves the OTLP protocol for the given signal ("traces", +// "metrics", "logs"). It honours the per-signal override +// OTEL_EXPORTER_OTLP__PROTOCOL, then the global +// OTEL_EXPORTER_OTLP_PROTOCOL, defaulting to "http/protobuf" per the +// OpenTelemetry spec. func otlpProtocol(signal string) string { key := "OTEL_EXPORTER_OTLP_" + strings.ToUpper(signal) + "_PROTOCOL" return env.Get(key, env.Get("OTEL_EXPORTER_OTLP_PROTOCOL", protocolHTTP)) diff --git a/telemetry/code.go b/telemetry/code.go index e1e34d29..49096212 100644 --- a/telemetry/code.go +++ b/telemetry/code.go @@ -6,6 +6,9 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.41.0" ) +// CodeCaller returns the code.function.name, code.file.path and +// code.line.number attributes of the caller skip frames above the caller of +// CodeCaller, or nil if the frame cannot be resolved. func CodeCaller(skip int) []attribute.KeyValue { if fr := goruntime.CallFrame(skip + 1); !fr.Zero() { return []attribute.KeyValue{ @@ -18,6 +21,8 @@ func CodeCaller(skip int) []attribute.KeyValue { return nil } +// CodeStacktrace returns a code.stacktrace attribute with up to num frames, +// skipping skip frames above the caller of CodeStacktrace. func CodeStacktrace(num, skip int) attribute.KeyValue { return semconv.CodeStacktrace(goruntime.StackTrace(num, skip+1)) } diff --git a/telemetry/config.go b/telemetry/config.go index 73f52d24..3abd69df 100644 --- a/telemetry/config.go +++ b/telemetry/config.go @@ -5,13 +5,17 @@ import ( "sync" ) +// Name is the instrumentation scope name used by [Tracer] and [Meter]. +// DefaultServiceName is the service name used by [NewProfiler] when +// OTEL_SERVICE_NAME is unset. var ( Name = "github.com/foomo/keel/telemetry" DefaultServiceName = "undefined" ) -// Version returns the keel module version for use as the OTel instrumentation -// scope version. Resolved once from build info; empty string if unavailable. +// Version returns the keel module version for use as the OTel +// instrumentation scope version. It is resolved once from the build info and +// is empty if unavailable. var Version = sync.OnceValue(func() string { bi, ok := debug.ReadBuildInfo() if !ok { diff --git a/telemetry/context.go b/telemetry/context.go index 5ae34968..611a4267 100644 --- a/telemetry/context.go +++ b/telemetry/context.go @@ -14,11 +14,14 @@ import ( "go.uber.org/zap/zapcore" ) +// Context wraps a [context.Context] and provides span, log and profile +// helpers bound to it. It implements [context.Context]. Create one with +// [Ctx]; the zero value is not usable. type Context struct { ctx context.Context } -// Ctx returns a new Context from the given context.Context. +// Ctx returns a [Context] wrapping ctx. func Ctx(ctx context.Context) Context { return Context{ctx} } @@ -27,22 +30,22 @@ func Ctx(ctx context.Context) Context { // ~ Log methods // ------------------------------------------------------------------------------------------------ -// LogDebug logs a message at `debug` level. +// LogDebug logs msg at debug level, see [Log]. func (c Context) LogDebug(msg string, kv ...attribute.KeyValue) { Log(c.ctx, zapcore.DebugLevel, msg, 1, kv...) } -// LogInfo logs a message at `info` level. +// LogInfo logs msg at info level, see [Log]. func (c Context) LogInfo(msg string, kv ...attribute.KeyValue) { Log(c.ctx, zapcore.InfoLevel, msg, 1, kv...) } -// LogWarn logs a message at `warn` level. +// LogWarn logs msg at warn level, see [Log]. func (c Context) LogWarn(msg string, kv ...attribute.KeyValue) { Log(c.ctx, zapcore.WarnLevel, msg, 1, kv...) } -// LogError logs a message at `error` level. +// LogError logs msg at error level, see [Log]. func (c Context) LogError(msg string, kv ...attribute.KeyValue) { Log(c.ctx, zapcore.ErrorLevel, msg, 1, kv...) } @@ -51,7 +54,7 @@ func (c Context) LogError(msg string, kv ...attribute.KeyValue) { // ~ Context methods // ------------------------------------------------------------------------------------------------ -// Context returns the underlying context.Context. +// Context returns the wrapped [context.Context]. func (c Context) Context() context.Context { return c.ctx } @@ -82,7 +85,7 @@ func (c Context) WithDeadline(deadline time.Time) (Context, context.CancelFunc) } // WithTimeout returns a copy of the context with a timeout. -// It is equivalent to ContextWith(time.Now().Add(timeout)). +// It is equivalent to WithDeadline(time.Now().Add(timeout)). func (c Context) WithTimeout(timeout time.Duration) (Context, context.CancelFunc) { ctx, cancel := context.WithTimeout(c.ctx, timeout) return Ctx(ctx), cancel @@ -128,48 +131,52 @@ func (c Context) Err() error { // ~ Trace methods // ------------------------------------------------------------------------------------------------ -// Span returns the span from the context. +// Span returns the current span of the context. func (c Context) Span() trace.Span { return SpanFromContext(c.ctx) } -// SetSpanDebug sets the span debug attribute. +// SetSpanDebug marks the current span as a debug span. func (c Context) SetSpanDebug() { SetSpanDebug(c.Span()) } -// EndSpan ends the span. +// EndSpan ends the current span, see [EndSpan]. func (c Context) EndSpan(err error, opts ...trace.SpanEndOption) { EndSpan(c.Span(), err, opts...) } -// DeferEndSpan is a helper, so you can do `defer ctx.DeferEndSpan(&err)` instead of `defer func(){ ctx.EndSpan(err) }()` +// DeferEndSpan ends the current span with the error err points to, so it +// can be used as defer ctx.DeferEndSpan(&err). See [DeferEndSpan]. func (c Context) DeferEndSpan(err *error, opts ...trace.SpanEndOption) { DeferEndSpan(c.Span(), err, opts...) } -// SetSpanStatusOK sets the status of the span to ok. +// SetSpanStatusOK sets the status of the current span to ok. func (c Context) SetSpanStatusOK() { c.Span().SetStatus(codes.Ok, "") } -// SetSpanStatusError sets the status of the span to error. +// SetSpanStatusError sets the status of the current span to error with the +// given description. func (c Context) SetSpanStatusError(description string) { c.Span().SetStatus(codes.Error, description) SetSpanStatusError(c.Span(), description) } -// SetSpanName sets the name of the span. +// SetSpanName sets the name of the current span. func (c Context) SetSpanName(name string) { SetSpanName(c.Span(), name) } -// SetSpanAttributes sets the attributes of the span. +// SetSpanAttributes sets attributes on the current span. func (c Context) SetSpanAttributes(kv ...attribute.KeyValue) { SetSpanAttributes(c.Span(), kv...) } -// RecordError records an error on the span and logs it. +// RecordError records a non-nil err with kv and a stack trace on the +// current span and sets the span status to error with the root cause +// message. It does not log the error. func (c Context) RecordError(err error, kv ...attribute.KeyValue) { if err != nil { sp := c.Span() @@ -181,7 +188,8 @@ func (c Context) RecordError(err error, kv ...attribute.KeyValue) { } } -// RecordSpanError records an error on the span. +// RecordSpanError records a non-nil err with kv and a stack trace on the +// current span without changing the span status. func (c Context) RecordSpanError(err error, kv ...attribute.KeyValue) { if err != nil { sp := c.Span() @@ -191,12 +199,12 @@ func (c Context) RecordSpanError(err error, kv ...attribute.KeyValue) { } } -// AddSpanEvent adds an event to the span. +// AddSpanEvent adds an event with attributes kv to the current span. func (c Context) AddSpanEvent(name string, kv ...attribute.KeyValue) { c.Span().AddEvent(name, trace.WithAttributes(kv...)) } -// AddSpanLink adds a link to the span. +// AddSpanLink adds a link from the current span to parent. func (c Context) AddSpanLink(parent trace.Span, attrs ...attribute.KeyValue) { sp := c.Span() sp.AddLink(trace.Link{ @@ -205,19 +213,22 @@ func (c Context) AddSpanLink(parent trace.Span, attrs ...attribute.KeyValue) { }) } -// StartSpan starts a span. +// StartSpan starts a child span named after the calling package and returns +// a Context holding it, see [StartSpan]. func (c Context) StartSpan(opts ...trace.SpanStartOption) Context { ctx, _ := StartSpanWithSkip(c.ctx, 1, opts...) return Ctx(ctx) } -// StartSpanWithNewRoot sets the name of the span. +// StartSpanWithNewRoot starts a span in a new trace, linked to the span of +// the context, and returns a Context holding it. func (c Context) StartSpanWithNewRoot(opts ...trace.SpanStartOption) Context { ctx, _ := StartSpanWithSkip(c.ctx, 1, append(opts, trace.WithNewRoot(), trace.WithLinks(trace.LinkFromContext(c.ctx)))...) return Ctx(ctx) } -// StartSpanWithProfile starts a span and profiles the handler. +// StartSpanWithProfile starts a span with attributes kv, runs handler within +// a profile named name (see [Context.StartProfile]) and ends the span. func (c Context) StartSpanWithProfile(name string, handler func(ctx Context), kv ...attribute.KeyValue) { ctx, span := StartSpanWithSkip(c.ctx, 1, trace.WithAttributes(kv...)) defer span.End() @@ -225,7 +236,8 @@ func (c Context) StartSpanWithProfile(name string, handler func(ctx Context), kv Ctx(ctx).StartProfile(name, handler, kv...) } -// StartProfile starts a profile for the handler. +// StartProfile runs handler with pyroscope labels for the profile name and +// kv applied and sets the profile name attribute on the current span. func (c Context) StartProfile(name string, handler func(ctx Context), kv ...attribute.KeyValue) { attr := foomosemconv.ProfileName(name) c.Span().SetAttributes(attr) @@ -234,7 +246,8 @@ func (c Context) StartProfile(name string, handler func(ctx Context), kv ...attr }) } -// SetProfileAttributes sets the labels for the profile. +// SetProfileAttributes adds kv as pprof labels to the context and the +// current goroutine and returns the labeled Context. func (c Context) SetProfileAttributes(kv ...attribute.KeyValue) Context { ctx := pprof.WithLabels(c.ctx, PyroscopeLabels(kv...)) pprof.SetGoroutineLabels(ctx) diff --git a/telemetry/deprecated.go b/telemetry/deprecated.go index 9e6f75a4..325ba41c 100644 --- a/telemetry/deprecated.go +++ b/telemetry/deprecated.go @@ -1,6 +1,9 @@ package telemetry var ( - // Deprecated: DefaultHistogramBuckets units are selected for metrics in "seconds" unit + // DefaultHistogramBuckets are histogram bucket boundaries for metrics in + // seconds. + // + // Deprecated: Configure buckets on the instrument instead. DefaultHistogramBuckets = []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 60, 120, 300, 600} ) diff --git a/telemetry/doc.go b/telemetry/doc.go new file mode 100644 index 00000000..567a6336 --- /dev/null +++ b/telemetry/doc.go @@ -0,0 +1,63 @@ +// Package telemetry provides OpenTelemetry tracing, metrics, logging and +// profiling helpers for keel services. +// +// # Wiring +// +// The usual entry point is [github.com/foomo/keel.WithTelemetry], which +// creates the trace, meter and logger providers from the environment through +// [NewTraceProviderFromEnv], [NewMeterProviderFromEnv] and +// [NewLoggerProviderFromEnv]. Every provider constructor in this package +// also installs its provider as the OpenTelemetry global, which [Tracer], +// [Meter] and [LoggerProvider] read. +// +// # Environment +// +// The exporters follow the OpenTelemetry environment variable specification: +// +// - OTEL_TRACES_EXPORTER, OTEL_LOGS_EXPORTER: none (default), console or otlp. +// - OTEL_METRICS_EXPORTER: none (default), console, otlp or prometheus. +// - OTEL_EXPORTER_OTLP_PROTOCOL and OTEL_EXPORTER_OTLP__PROTOCOL: +// http/protobuf (default) or grpc. +// - OTEL_EXPORTER_OTLP_* endpoint and header variables are read by the +// OTLP exporters themselves. +// - OTEL_SERVICE_NAME, OTEL_SERVICE_NAMESPACE and OTEL_RESOURCE_ATTRIBUTES +// populate the resource, see [NewResource]. +// +// keel specific variables: +// +// - OTEL_TRACE_RATIO: parent based trace sampling ratio, defaults to 1. +// - OTEL_METRICS_HOST_ENABLED, OTEL_METRICS_RUNTIME_ENABLED: start host and +// Go runtime metric instrumentation. +// - OTEL_EXPORTER_STDOUT_PRETTY_PRINT, OTEL_EXPORTER_STDOUT_TIMESTAMPS: +// console exporter formatting, both default to true. +// - OTEL_PROFILE_*: optional profile types of [NewProfiler]. +// +// # Spans and logs +// +// [Ctx] wraps a [context.Context] in a [Context] that bundles span, log and +// profile helpers. Spans are named after the calling package and annotated +// with the caller's function, file and line: +// +// func (s *Service) Do(ctx context.Context) (err error) { +// tctx := telemetry.Ctx(ctx).StartSpan() +// defer tctx.DeferEndSpan(&err) +// +// tctx.LogInfo("doing work", attribute.String("id", s.id)) +// +// if err := s.work(tctx.Context()); err != nil { +// tctx.RecordError(err) +// return err +// } +// +// return nil +// } +// +// Log messages are written through the global zap logger and carry the +// trace and span IDs of the context. +// +// # Metrics +// +// [NewIntCounter], [NewFloatHistogram] and their siblings create instruments +// from [Meter]. On error they report through [otel.Handle] and return a +// no-op instrument instead of nil. +package telemetry diff --git a/telemetry/logger.go b/telemetry/logger.go index 308a071b..4a8a1a70 100644 --- a/telemetry/logger.go +++ b/telemetry/logger.go @@ -14,22 +14,30 @@ import ( "go.uber.org/zap/zapcore" ) +// LogWarn logs msg at warn level, see [Log]. func LogWarn(ctx context.Context, msg string, kv ...attribute.KeyValue) { Log(ctx, zapcore.WarnLevel, msg, 1, kv...) } +// LogError logs msg at error level, see [Log]. func LogError(ctx context.Context, msg string, kv ...attribute.KeyValue) { Log(ctx, zapcore.ErrorLevel, msg, 1, kv...) } +// LogDebug logs msg at debug level, see [Log]. func LogDebug(ctx context.Context, msg string, kv ...attribute.KeyValue) { Log(ctx, zapcore.DebugLevel, msg, 1, kv...) } +// LogInfo logs msg at info level, see [Log]. func LogInfo(ctx context.Context, msg string, kv ...attribute.KeyValue) { Log(ctx, zapcore.InfoLevel, msg, 1, kv...) } +// Log writes msg at level lvl to the global zap logger with kv as fields. +// It adds the trace and span IDs of a valid span in ctx and the code +// location of the caller skip frames above the caller of Log. Nothing is +// done if lvl is not enabled. func Log(ctx context.Context, lvl zapcore.Level, msg string, skip int, kv ...attribute.KeyValue) { if !zap.L().Core().Enabled(lvl) { return diff --git a/telemetry/loggerprovider.go b/telemetry/loggerprovider.go index 0d269136..a73356b1 100644 --- a/telemetry/loggerprovider.go +++ b/telemetry/loggerprovider.go @@ -25,18 +25,19 @@ func LoggerProvider() log.LoggerProvider { return global.GetLoggerProvider() } -// NewNoopLoggerProvider returns a no-op log.LoggerProvider. +// NewNoopLoggerProvider returns a no-op [log.LoggerProvider]. func NewNoopLoggerProvider() log.LoggerProvider { return noop.NewLoggerProvider() } -// NewZapBridgeCore returns a zapcore.Core that emits entries as OTEL log +// NewZapBridgeCore returns a [zapcore.Core] that emits entries as OTEL log // records through lp, for teeing into an existing logger. func NewZapBridgeCore(lp log.LoggerProvider) zapcore.Core { return otelzapbridge.NewCore(instrumentationName, otelzapbridge.WithLoggerProvider(lp)) } -// NewZapLoggerProvider creates a new log.LoggerProvider using a Zap logger for structured logging with OpenTelemetry. +// NewZapLoggerProvider returns a logger provider that writes OTEL log +// records to logger and installs it as the global logger provider. func NewZapLoggerProvider(ctx context.Context, logger *zap.Logger) (log.LoggerProvider, error) { exp, err := otelzap.New(logger) if err != nil { @@ -46,7 +47,10 @@ func NewZapLoggerProvider(ctx context.Context, logger *zap.Logger) (log.LoggerPr return newLoggerProvider(ctx, sdklog.NewSimpleProcessor(exp)) } -// NewStdOutLoggerProvider creates a logger provider that exports logs to standard output with configurable options. +// NewStdOutLoggerProvider returns a logger provider that exports logs to +// stdout and installs it as the global logger provider. Output formatting is +// controlled by OTEL_EXPORTER_STDOUT_PRETTY_PRINT and +// OTEL_EXPORTER_STDOUT_TIMESTAMPS, both defaulting to true. func NewStdOutLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { var opts []stdoutlog.Option if env.GetBool("OTEL_EXPORTER_STDOUT_PRETTY_PRINT", true) { @@ -65,7 +69,9 @@ func NewStdOutLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { return newLoggerProvider(ctx, sdklog.NewSimpleProcessor(exp)) } -// NewOTLPHTTPLoggerProvider creates a new OTLP HTTP LoggerProvider with a batch processor and default resource. +// NewOTLPHTTPLoggerProvider returns a logger provider that batches and +// exports logs over OTLP HTTP and installs it as the global logger provider. +// The exporter is configured from the OTEL_EXPORTER_OTLP_* environment. func NewOTLPHTTPLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { exp, err := otlploghttp.New(ctx) if err != nil { @@ -75,7 +81,9 @@ func NewOTLPHTTPLoggerProvider(ctx context.Context) (log.LoggerProvider, error) return newLoggerProvider(ctx, sdklog.NewBatchProcessor(exp)) } -// NewOTLPGRPCLoggerProvider creates a new OTLP gRPC-based logger provider using the provided context. +// NewOTLPGRPCLoggerProvider returns a logger provider that batches and +// exports logs over OTLP gRPC and installs it as the global logger provider. +// The exporter is configured from the OTEL_EXPORTER_OTLP_* environment. func NewOTLPGRPCLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { exp, err := otlploggrpc.New(ctx) if err != nil { @@ -85,11 +93,16 @@ func NewOTLPGRPCLoggerProvider(ctx context.Context) (log.LoggerProvider, error) return newLoggerProvider(ctx, sdklog.NewBatchProcessor(exp)) } -// Deprecated: use NewOTLPGRPCLoggerProvider (this was a misspelling). +// NewOTLPGRCPLoggerProvider is a misspelled alias of +// [NewOTLPGRPCLoggerProvider]. +// +// Deprecated: Use [NewOTLPGRPCLoggerProvider] instead. func NewOTLPGRCPLoggerProvider(ctx context.Context) (log.LoggerProvider, error) { return NewOTLPGRPCLoggerProvider(ctx) } +// newLoggerProvider creates an SDK logger provider with the default resource +// and processor p and installs it as the global logger provider. func newLoggerProvider(ctx context.Context, p sdklog.Processor) (log.LoggerProvider, error) { resource, err := NewResource(ctx) if err != nil { diff --git a/telemetry/meter.go b/telemetry/meter.go index 07b2bd50..5066f48b 100644 --- a/telemetry/meter.go +++ b/telemetry/meter.go @@ -4,9 +4,9 @@ import ( "go.opentelemetry.io/otel/metric" ) -// Meter returns a metric.Meter instance with the provided options, using the default MeterProvider and the defined Name. -// The instrumentation scope version defaults to the keel module version; pass a -// metric.WithInstrumentationVersion option to override it. +// Meter returns a [metric.Meter] named [Name] from the global +// [MeterProvider]. The instrumentation scope version defaults to [Version]; +// pass [metric.WithInstrumentationVersion] to override it. func Meter(opts ...metric.MeterOption) metric.Meter { return MeterProvider().Meter(Name, append([]metric.MeterOption{metric.WithInstrumentationVersion(Version())}, opts...)...) } diff --git a/telemetry/meterprovider.go b/telemetry/meterprovider.go index f2c59253..fe342a57 100644 --- a/telemetry/meterprovider.go +++ b/telemetry/meterprovider.go @@ -18,17 +18,21 @@ import ( sdkmetric "go.opentelemetry.io/otel/sdk/metric" ) -// MeterProvider returns the default metric.MeterProvider instance for creating meters. +// MeterProvider returns the global [metric.MeterProvider]. func MeterProvider() metric.MeterProvider { return otel.GetMeterProvider() } -// NewNoopMeterProvider returns a no-op metric.MeterProvider. +// NewNoopMeterProvider returns a no-op [metric.MeterProvider]. func NewNoopMeterProvider() metric.MeterProvider { return noop.NewMeterProvider() } -// NewStdOutMeterProvider creates a new MeterProvider that exports metrics to standard output with configurable options. +// NewStdOutMeterProvider returns a meter provider that periodically exports +// metrics to stdout and installs it as the global meter provider. Output +// formatting is controlled by OTEL_EXPORTER_STDOUT_PRETTY_PRINT and +// OTEL_EXPORTER_STDOUT_TIMESTAMPS, both defaulting to true; opts are passed +// to the exporter. func NewStdOutMeterProvider(ctx context.Context, opts ...stdoutmetric.Option) (metric.MeterProvider, error) { if env.GetBool("OTEL_EXPORTER_STDOUT_PRETTY_PRINT", true) { enc := json.NewEncoder(os.Stdout) @@ -54,6 +58,7 @@ func NewStdOutMeterProvider(ctx context.Context, opts ...stdoutmetric.Option) (m // over OTLP gRPC via a periodic reader. It configures the exporter from environment // variables (e.g. endpoint, insecure transport) unless overridden by the given options. // Push-based export suits short-lived workloads (e.g. Jobs) that exit before a scrape. +// The provider is installed as the global meter provider. func NewOTLPGRPCMeterProvider(ctx context.Context, opts ...otlpmetricgrpc.Option) (metric.MeterProvider, error) { exporter, err := otlpmetricgrpc.New(ctx, opts...) if err != nil { @@ -67,6 +72,7 @@ func NewOTLPGRPCMeterProvider(ctx context.Context, opts ...otlpmetricgrpc.Option // over OTLP HTTP via a periodic reader. It configures the exporter from environment // variables (e.g. endpoint, insecure transport) unless overridden by the given options. // Push-based export suits short-lived workloads (e.g. Jobs) that exit before a scrape. +// The provider is installed as the global meter provider. func NewOTLPHTTPMeterProvider(ctx context.Context, opts ...otlpmetrichttp.Option) (metric.MeterProvider, error) { exporter, err := otlpmetrichttp.New(ctx, opts...) if err != nil { @@ -76,7 +82,9 @@ func NewOTLPHTTPMeterProvider(ctx context.Context, opts ...otlpmetrichttp.Option return newMeterProvider(ctx, sdkmetric.NewPeriodicReader(exporter)) } -// NewPrometheusMeterProvider initializes and returns a Prometheus-based metric.MeterProvider with default configuration. +// NewPrometheusMeterProvider returns a meter provider backed by the OTEL +// Prometheus exporter, which registers into prometheus.DefaultRegisterer, and +// installs it as the global meter provider. func NewPrometheusMeterProvider(ctx context.Context) (metric.MeterProvider, error) { exporter, err := prometheus.New() if err != nil { @@ -86,6 +94,10 @@ func NewPrometheusMeterProvider(ctx context.Context) (metric.MeterProvider, erro return newMeterProvider(ctx, exporter) } +// newMeterProvider creates an SDK meter provider with the default resource +// and reader r and installs it as the global meter provider. It starts host +// and runtime instrumentation if OTEL_METRICS_HOST_ENABLED or +// OTEL_METRICS_RUNTIME_ENABLED are set. func newMeterProvider(ctx context.Context, r sdkmetric.Reader) (metric.MeterProvider, error) { if env.GetBool("OTEL_METRICS_HOST_ENABLED", false) { if err := otelhost.Start(); err != nil { diff --git a/telemetry/profiler.go b/telemetry/profiler.go index 67162afd..a1258de4 100644 --- a/telemetry/profiler.go +++ b/telemetry/profiler.go @@ -13,6 +13,13 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.41.0" ) +// NewProfiler starts a pyroscope profiler named after the service and tagged +// with the pod name, service namespace and resource attributes. CPU and +// memory profiles are always collected; block, mutex and goroutine profiles +// are enabled with OTEL_PROFILE_BLOCK_ENABLED, OTEL_PROFILE_MUTEX_ENABLED and +// OTEL_PROFILE_GOROUTINES_ENABLED, with rates from OTEL_PROFILE_BLOCK_RATE and +// OTEL_PROFILE_MUTEX_FRACTION (default 5). It also wraps the global tracer +// provider so spans are linked to profiles. func NewProfiler(ctx context.Context) (*pyroscope.Profiler, error) { tags := map[string]string{} if v := os.Getenv("HOSTNAME"); v != "" { diff --git a/telemetry/pyroscope.go b/telemetry/pyroscope.go index ce958603..bebe5d1c 100644 --- a/telemetry/pyroscope.go +++ b/telemetry/pyroscope.go @@ -7,6 +7,8 @@ import ( "go.opentelemetry.io/otel/attribute" ) +// PyroscopeLabels converts the valid attributes in kv to pyroscope labels, +// replacing dots in keys with underscores. func PyroscopeLabels(kv ...attribute.KeyValue) pyroscope.LabelSet { var labels []string diff --git a/telemetry/resource.go b/telemetry/resource.go index 903d30be..2a6273da 100644 --- a/telemetry/resource.go +++ b/telemetry/resource.go @@ -23,6 +23,9 @@ var envAttributes = map[attribute.Key][]string{ "vcs.repository.path": {"REPO_PATH", "REPOSITORY_PATH", "GIT_REPOSITORY_PATH", "OTEL_VCS_ROOT_PATH"}, } +// EnvAttributes returns the service namespace and VCS attributes read from +// well known environment variables. For each attribute the first non-empty +// variable wins. func EnvAttributes() []attribute.KeyValue { var attrs []attribute.KeyValue @@ -38,7 +41,9 @@ func EnvAttributes() []attribute.KeyValue { return attrs } -// NewResource creates and returns a default resource for telemetry data, using the provided context. +// NewResource returns the resource attached to all providers of this +// package. It combines OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES and +// [EnvAttributes]. func NewResource(ctx context.Context) (*resource.Resource, error) { return resource.New(ctx, resource.WithFromEnv(), diff --git a/telemetry/span.go b/telemetry/span.go index 5bc76fa7..62501e83 100644 --- a/telemetry/span.go +++ b/telemetry/span.go @@ -13,32 +13,36 @@ import ( "go.opentelemetry.io/otel/trace" ) -// Deprecated: use StartSpan instead. +// Start starts a span like [StartSpan]; spanName is ignored. +// +// Deprecated: Use [StartSpan] instead. func Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { return StartSpanWithSkip(ctx, 1, opts...) } -// StartSpan starts a new span with the given name and options. +// StartSpan starts a span named after the calling package, annotated with +// the caller's function, file and line, from the global [Tracer]. func StartSpan(ctx context.Context, opts ...trace.SpanStartOption) (context.Context, trace.Span) { return StartSpanWithSkip(ctx, 1, opts...) } -// StartDebugSpan starts a new span with the given name and options and adds the debug attr. +// StartDebugSpan is like [StartSpan] but marks the span as a debug span. func StartDebugSpan(ctx context.Context, opts ...trace.SpanStartOption) (context.Context, trace.Span) { return StartSpanWithSkip(ctx, 1, append(opts, trace.WithAttributes(keelsemconv.DebugEnabled(true)))...) } -// SpanFromContext returns the span from the context. +// SpanFromContext returns the current span of ctx, or a non-recording span +// if there is none. func SpanFromContext(ctx context.Context) trace.Span { return trace.SpanFromContext(ctx) } -// AddSpanEvent adds an event to the span. +// AddSpanEvent adds an event to sp. func AddSpanEvent(sp trace.Span, name string, opts ...trace.EventOption) { sp.AddEvent(name, opts...) } -// AddSpanLink adds a link to the span. +// AddSpanLink adds a link from sp to parent with attrs. func AddSpanLink(sp, parent trace.Span, attrs ...attribute.KeyValue) { sp.AddLink(trace.Link{ SpanContext: parent.SpanContext(), @@ -46,37 +50,39 @@ func AddSpanLink(sp, parent trace.Span, attrs ...attribute.KeyValue) { }) } -// SetSpanAttributes sets attributes on the span. +// SetSpanAttributes sets attrs on sp. func SetSpanAttributes(sp trace.Span, attrs ...attribute.KeyValue) { sp.SetAttributes(attrs...) } -// SetSpanName sets the name of the span. +// SetSpanName sets the name of sp. func SetSpanName(sp trace.Span, name string) { sp.SetName(name) } -// IsSpanRecording returns true if the span is recording. +// IsSpanRecording reports whether sp is recording. func IsSpanRecording(sp trace.Span) bool { return sp.IsRecording() } -// SetSpanDebug sets the span debug attribute. +// SetSpanDebug marks sp as a debug span. func SetSpanDebug(sp trace.Span) { sp.SetAttributes(keelsemconv.DebugEnabled(true)) } -// SetSpanStatusOK sets the status of the span to ok. +// SetSpanStatusOK sets the status of sp to ok. func SetSpanStatusOK(sp trace.Span) { sp.SetStatus(codes.Ok, "") } -// SetSpanStatusError sets the status of the span to error. +// SetSpanStatusError sets the status of sp to error with description. func SetSpanStatusError(sp trace.Span, description string) { sp.SetStatus(codes.Error, description) } -// Deprecated: use EndSpan instead. +// End ends sp like [EndSpan]. +// +// Deprecated: Use [EndSpan] instead. func End(sp trace.Span, err error) { if err != nil { sp.RecordError(err, trace.WithAttributes(CodeStacktrace(3, 0))) @@ -86,7 +92,8 @@ func End(sp trace.Span, err error) { sp.End() } -// EndSpan ends the span. +// EndSpan ends sp. A non-nil err is recorded with a stack trace and sets the +// span status to error with the root cause message. func EndSpan(sp trace.Span, err error, opts ...trace.SpanEndOption) { if err != nil { sp.RecordError(err, trace.WithAttributes(CodeStacktrace(3, 0))) @@ -96,7 +103,9 @@ func EndSpan(sp trace.Span, err error, opts ...trace.SpanEndOption) { sp.End(opts...) } -// DeferEndSpan is a helper, so you can do `defer ctx.DeferEndSpan(&err)` instead of `defer func(){ ctx.EndSpan(err) }()` +// DeferEndSpan ends sp with the error err points to, so it can be used as +// defer DeferEndSpan(sp, &err). A nil err pointer ends the span without +// error. func DeferEndSpan(sp trace.Span, err *error, opts ...trace.SpanEndOption) { if err == nil { EndSpan(sp, nil, opts...) @@ -107,8 +116,9 @@ func DeferEndSpan(sp trace.Span, err *error, opts ...trace.SpanEndOption) { EndSpan(sp, *err, opts...) } -// StartSpanWithSkip starts a span with skip. -// It's only supposed to be used internally or by other telemetry packages +// StartSpanWithSkip is like [StartSpan] but uses the caller skip frames above +// the caller of StartSpanWithSkip for the span name and code attributes. It +// is intended for telemetry helper packages. func StartSpanWithSkip(ctx context.Context, skip int, opts ...trace.SpanStartOption) (context.Context, trace.Span) { name := "runtime.go" diff --git a/telemetry/telemetrytest/doc.go b/telemetry/telemetrytest/doc.go new file mode 100644 index 00000000..070cdc94 --- /dev/null +++ b/telemetry/telemetrytest/doc.go @@ -0,0 +1,3 @@ +// Package telemetrytest provides helpers for testing code instrumented with +// [github.com/foomo/keel/telemetry]. +package telemetrytest diff --git a/telemetry/telemetrytest/tracer.go b/telemetry/telemetrytest/tracer.go index 190aeb4b..857f454d 100644 --- a/telemetry/telemetrytest/tracer.go +++ b/telemetry/telemetrytest/tracer.go @@ -7,6 +7,8 @@ import ( "go.opentelemetry.io/otel/trace" ) +// NewTestTraceProvider returns a tracer provider that records all spans in +// the returned span recorder and installs it as the global tracer provider. func NewTestTraceProvider() (*tracetest.SpanRecorder, trace.TracerProvider) { spanRecorder := tracetest.NewSpanRecorder() tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) diff --git a/telemetry/traceprovider.go b/telemetry/traceprovider.go index 0e8e0011..780b2425 100644 --- a/telemetry/traceprovider.go +++ b/telemetry/traceprovider.go @@ -14,7 +14,7 @@ import ( "github.com/foomo/keel/env" ) -// TracerProvider returns the global TracerProvider instance used for creating tracers. +// TracerProvider returns the global [trace.TracerProvider]. func TracerProvider() trace.TracerProvider { return otel.GetTracerProvider() } @@ -26,7 +26,7 @@ func NewNoopTraceProvider() trace.TracerProvider { // NewStdOutTraceProvider creates a new stdout trace provider with optional configurations from the environment variables. // It uses pretty print and timestamps by default unless overridden by the respective environment variables. -// Returns a configured trace.TracerProvider or an error if the setup fails. +// The provider is installed as the global tracer provider. func NewStdOutTraceProvider(ctx context.Context) (trace.TracerProvider, error) { var opts []stdouttrace.Option if env.GetBool("OTEL_EXPORTER_STDOUT_PRETTY_PRINT", true) { @@ -47,7 +47,7 @@ func NewStdOutTraceProvider(ctx context.Context) (trace.TracerProvider, error) { // NewOTLPHTTPTraceProvider creates an OTLP HTTP trace provider using the given context and options. // It configures the provider based on environment variables, such as endpoint and insecure transport. -// Returns a configured trace.TracerProvider instance or an error if the initialization fails. +// The provider is installed as the global tracer provider. func NewOTLPHTTPTraceProvider(ctx context.Context, opts ...otlptracehttp.Option) (trace.TracerProvider, error) { exporter, err := otlptracehttp.New(ctx, opts...) if err != nil { @@ -59,7 +59,7 @@ func NewOTLPHTTPTraceProvider(ctx context.Context, opts ...otlptracehttp.Option) // NewOTLPGRPCTraceProvider creates a new trace provider configured for OTLP over gRPC with optional settings. // It uses environment variables for settings like endpoint and insecure mode if not provided explicitly. -// Returns a configured TracerProvider or an error if initialization fails. +// The provider is installed as the global tracer provider. func NewOTLPGRPCTraceProvider(ctx context.Context, opts ...otlptracegrpc.Option) (trace.TracerProvider, error) { exporter, err := otlptracegrpc.New(ctx, opts...) if err != nil { @@ -69,6 +69,9 @@ func NewOTLPGRPCTraceProvider(ctx context.Context, opts ...otlptracegrpc.Option) return newTracerProvider(ctx, exporter) } +// newTracerProvider creates an SDK tracer provider batching to exp with the +// default resource and a parent based sampler using OTEL_TRACE_RATIO +// (default 1), and installs it as the global tracer provider. func newTracerProvider(ctx context.Context, exp sdktrace.SpanExporter) (trace.TracerProvider, error) { resource, err := NewResource(ctx) if err != nil { diff --git a/telemetry/tracer.go b/telemetry/tracer.go index 10abc60d..74eb3a56 100644 --- a/telemetry/tracer.go +++ b/telemetry/tracer.go @@ -4,9 +4,9 @@ import ( "go.opentelemetry.io/otel/trace" ) -// Tracer returns a trace.Tracer instance configured with optional TracerOptions. -// The instrumentation scope version defaults to the keel module version; pass a -// trace.WithInstrumentationVersion option to override it. +// Tracer returns a [trace.Tracer] named [Name] from the global +// [TracerProvider]. The instrumentation scope version defaults to [Version]; +// pass [trace.WithInstrumentationVersion] to override it. func Tracer(opts ...trace.TracerOption) trace.Tracer { return TracerProvider().Tracer(Name, append([]trace.TracerOption{trace.WithInstrumentationVersion(Version())}, opts...)...) } diff --git a/time/doc.go b/time/doc.go new file mode 100644 index 00000000..d8b02a74 --- /dev/null +++ b/time/doc.go @@ -0,0 +1,7 @@ +// Package keeltime provides a replaceable clock for deterministic tests. +// +// Code reads the current time through [Now]. Tests can switch it to a fixed +// time with [Static] or to a strictly increasing time with [Incremental]. +// The package variables are not synchronized; switch providers before +// concurrent use. +package keeltime diff --git a/time/time.go b/time/time.go index 8bddc78f..11ec43e7 100644 --- a/time/time.go +++ b/time/time.go @@ -5,25 +5,34 @@ import ( ) var ( - Now = time.Now - NowStaticNSec = int64(1609498800e9) // 2021-01-01 12:00:00 + // Now returns the current time. It defaults to [time.Now] and is replaced + // by [Static] and [Incremental]. + Now = time.Now + // NowStaticNSec is the Unix time in nanoseconds returned by the static + // provider (2021-01-01 11:00:00 UTC). + NowStaticNSec = int64(1609498800e9) + // NowIncrementalNSec is the Unix time in nanoseconds the incremental + // provider returns next. NowIncrementalNSec = NowStaticNSec ) -// Static sets now to a static time provider +// Static sets [Now] to always return [NowStaticNSec]. func Static() { Now = static } -// Incremental sets now to a incremental time provider +// Incremental sets [Now] to return [NowIncrementalNSec] and advance it by one +// nanosecond on each call. It is not safe for concurrent use. func Incremental() { Now = incremental } +// static returns the time at NowStaticNSec. func static() time.Time { return time.Unix(0, NowStaticNSec) } +// incremental returns the time at NowIncrementalNSec and increments it. func incremental() time.Time { t := time.Unix(0, NowIncrementalNSec) NowIncrementalNSec++ @@ -31,7 +40,7 @@ func incremental() time.Time { return t } -// ResetIncremental sets the incremental time to the static default +// ResetIncremental resets [NowIncrementalNSec] to [NowStaticNSec]. func ResetIncremental() { NowIncrementalNSec = NowStaticNSec } diff --git a/utils/net/http/basicauth.go b/utils/net/http/basicauth.go index efe6c333..a27de37e 100644 --- a/utils/net/http/basicauth.go +++ b/utils/net/http/basicauth.go @@ -4,6 +4,8 @@ import ( "golang.org/x/crypto/bcrypt" ) +// HashBasicAuthPassword returns the bcrypt hash of the password v using +// [bcrypt.DefaultCost], suitable for storing basic auth credentials. func HashBasicAuthPassword(v []byte) ([]byte, error) { return bcrypt.GenerateFromPassword(v, bcrypt.DefaultCost) } diff --git a/utils/net/http/doc.go b/utils/net/http/doc.go new file mode 100644 index 00000000..1ff827ba --- /dev/null +++ b/utils/net/http/doc.go @@ -0,0 +1,12 @@ +// Package httputils provides helpers for HTTP handlers: writing error +// responses with logging and telemetry, inspecting request host and client +// address, and hashing basic auth passwords. +// +// Each status-specific helper such as [NotFoundServerError] or +// [InternalServerError] delegates to [ServerError]: +// +// if err != nil { +// httputils.InternalServerError(l, w, r, err) +// return +// } +package httputils diff --git a/utils/net/http/errors.go b/utils/net/http/errors.go index be7942d4..b20b1952 100644 --- a/utils/net/http/errors.go +++ b/utils/net/http/errors.go @@ -13,207 +13,215 @@ import ( "github.com/foomo/keel/log" ) -// InternalServerError http response +// InternalServerError responds with status [http.StatusInternalServerError] using [ServerError]. func InternalServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusInternalServerError, errs...) } -// InternalServiceUnavailable http response +// InternalServiceUnavailable responds with status [http.StatusServiceUnavailable] using [ServerError]. func InternalServiceUnavailable(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusServiceUnavailable, errs...) } -// InternalServiceTooEarly http response +// InternalServiceTooEarly responds with status [http.StatusTooEarly] using [ServerError]. func InternalServiceTooEarly(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusTooEarly, errs...) } -// UnauthorizedServerError http response +// UnauthorizedServerError responds with status [http.StatusUnauthorized] using [ServerError]. func UnauthorizedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusUnauthorized, errs...) } -// BadRequestServerError http response +// BadRequestServerError responds with status [http.StatusBadRequest] using [ServerError]. func BadRequestServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusBadRequest, errs...) } -// MethodNotAllowedServerError http response +// MethodNotAllowedServerError responds with status [http.StatusMethodNotAllowed] using [ServerError]. func MethodNotAllowedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusMethodNotAllowed, errs...) } -// RequestEntityTooLargeServerError http response +// RequestEntityTooLargeServerError responds with status [http.StatusRequestEntityTooLarge] using [ServerError]. func RequestEntityTooLargeServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusRequestEntityTooLarge, errs...) } -// NotFoundServerError http response +// NotFoundServerError responds with status [http.StatusNotFound] using [ServerError]. func NotFoundServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusNotFound, errs...) } -// PaymentRequiredServerError http response +// PaymentRequiredServerError responds with status [http.StatusPaymentRequired] using [ServerError]. func PaymentRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusPaymentRequired, errs...) } -// ForbiddenServerError http response +// ForbiddenServerError responds with status [http.StatusForbidden] using [ServerError]. func ForbiddenServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusForbidden, errs...) } -// NotAcceptableServerError http response +// NotAcceptableServerError responds with status [http.StatusNotAcceptable] using [ServerError]. func NotAcceptableServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusNotAcceptable, errs...) } -// ProxyAuthRequiredServerError http response +// ProxyAuthRequiredServerError responds with status [http.StatusProxyAuthRequired] using [ServerError]. func ProxyAuthRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusProxyAuthRequired, errs...) } -// RequestTimeoutServerError http response +// RequestTimeoutServerError responds with status [http.StatusRequestTimeout] using [ServerError]. func RequestTimeoutServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusRequestTimeout, errs...) } -// ConflictServerError http response +// ConflictServerError responds with status [http.StatusConflict] using [ServerError]. func ConflictServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusConflict, errs...) } -// GoneServerError http response +// GoneServerError responds with status [http.StatusGone] using [ServerError]. func GoneServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusGone, errs...) } -// LengthRequiredServerError http response +// LengthRequiredServerError responds with status [http.StatusLengthRequired] using [ServerError]. func LengthRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusLengthRequired, errs...) } -// PreconditionFailedServerError http response +// PreconditionFailedServerError responds with status [http.StatusPreconditionFailed] using [ServerError]. func PreconditionFailedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusPreconditionFailed, errs...) } -// RequestURITooLongServerError http response +// RequestURITooLongServerError responds with status [http.StatusRequestURITooLong] using [ServerError]. func RequestURITooLongServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusRequestURITooLong, errs...) } -// UnsupportedMediaTypeServerError http response +// UnsupportedMediaTypeServerError responds with status [http.StatusUnsupportedMediaType] using [ServerError]. func UnsupportedMediaTypeServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusUnsupportedMediaType, errs...) } -// RequestedRangeNotSatisfiableServerError http response +// RequestedRangeNotSatisfiableServerError responds with status [http.StatusRequestedRangeNotSatisfiable] using [ServerError]. func RequestedRangeNotSatisfiableServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusRequestedRangeNotSatisfiable, errs...) } -// ExpectationFailedServerError http response +// ExpectationFailedServerError responds with status [http.StatusExpectationFailed] using [ServerError]. func ExpectationFailedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusExpectationFailed, errs...) } -// TeapotServerError http response +// TeapotServerError responds with status [http.StatusTeapot] using [ServerError]. func TeapotServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusTeapot, errs...) } -// MisdirectedRequestServerError http response +// MisdirectedRequestServerError responds with status [http.StatusMisdirectedRequest] using [ServerError]. func MisdirectedRequestServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusMisdirectedRequest, errs...) } -// UnprocessableEntityServerError http response +// UnprocessableEntityServerError responds with status [http.StatusUnprocessableEntity] using [ServerError]. func UnprocessableEntityServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusUnprocessableEntity, errs...) } -// LockedServerError http response +// LockedServerError responds with status [http.StatusLocked] using [ServerError]. func LockedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusLocked, errs...) } -// FailedDependencyServerError http response +// FailedDependencyServerError responds with status [http.StatusFailedDependency] using [ServerError]. func FailedDependencyServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusFailedDependency, errs...) } -// UpgradeRequiredServerError http response +// UpgradeRequiredServerError responds with status [http.StatusUpgradeRequired] using [ServerError]. func UpgradeRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusUpgradeRequired, errs...) } -// PreconditionRequiredServerError http response +// PreconditionRequiredServerError responds with status [http.StatusPreconditionRequired] using [ServerError]. func PreconditionRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusPreconditionRequired, errs...) } -// TooManyRequestsServerError http response +// TooManyRequestsServerError responds with status [http.StatusTooManyRequests] using [ServerError]. func TooManyRequestsServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusTooManyRequests, errs...) } -// RequestHeaderFieldsTooLargeServerError http response +// RequestHeaderFieldsTooLargeServerError responds with status [http.StatusRequestHeaderFieldsTooLarge] using [ServerError]. func RequestHeaderFieldsTooLargeServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusRequestHeaderFieldsTooLarge, errs...) } -// UnavailableForLegalReasonsServerError http response +// UnavailableForLegalReasonsServerError responds with status [http.StatusUnavailableForLegalReasons] using [ServerError]. func UnavailableForLegalReasonsServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusUnavailableForLegalReasons, errs...) } -// NotImplementedServerError http response +// NotImplementedServerError responds with status [http.StatusNotImplemented] using [ServerError]. func NotImplementedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusNotImplemented, errs...) } -// BadGatewayServerError http response +// BadGatewayServerError responds with status [http.StatusBadGateway] using [ServerError]. func BadGatewayServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusBadGateway, errs...) } -// GatewayTimeoutServerError http response +// GatewayTimeoutServerError responds with status [http.StatusGatewayTimeout] using [ServerError]. func GatewayTimeoutServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusGatewayTimeout, errs...) } -// HTTPVersionNotSupportedServerError http response +// HTTPVersionNotSupportedServerError responds with status [http.StatusHTTPVersionNotSupported] using [ServerError]. func HTTPVersionNotSupportedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusHTTPVersionNotSupported, errs...) } -// VariantAlsoNegotiatesServerError http response +// VariantAlsoNegotiatesServerError responds with status [http.StatusVariantAlsoNegotiates] using [ServerError]. func VariantAlsoNegotiatesServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusVariantAlsoNegotiates, errs...) } -// InsufficientStorageServerError http response +// InsufficientStorageServerError responds with status [http.StatusInsufficientStorage] using [ServerError]. func InsufficientStorageServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusInsufficientStorage, errs...) } -// LoopDetectedServerError http response +// LoopDetectedServerError responds with status [http.StatusLoopDetected] using [ServerError]. func LoopDetectedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusLoopDetected, errs...) } -// NotExtendedServerError http response +// NotExtendedServerError responds with status [http.StatusNotExtended] using [ServerError]. func NotExtendedServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusNotExtended, errs...) } -// NetworkAuthenticationRequiredServerError http response +// NetworkAuthenticationRequiredServerError responds with status [http.StatusNetworkAuthenticationRequired] using [ServerError]. func NetworkAuthenticationRequiredServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, errs ...error) { ServerError(l, w, r, http.StatusNetworkAuthenticationRequired, errs...) } -// ServerError http response +// ServerError writes a plain-text response with the given status code and its +// [http.StatusText] as body, if errs joined by [errors.Join] is non-nil. +// +// The error is recorded on the current span and its type added to the +// otelhttp labeler, if any. If r carries a request log labeler (see +// [httplog.InjectLabelerIntoRequest]), the error is added to it; otherwise it +// is logged at error level through l, unless l is nil. +// +// When errs is empty or contains only nil errors, nothing is written to w. func ServerError(l *zap.Logger, w http.ResponseWriter, r *http.Request, code int, errs ...error) { if err := errors.Join(errs...); err != nil { errType := semconv.ErrorType(err) diff --git a/utils/net/http/request.go b/utils/net/http/request.go index 1ebf641d..d4cef5be 100644 --- a/utils/net/http/request.go +++ b/utils/net/http/request.go @@ -8,7 +8,9 @@ import ( keelhttp "github.com/foomo/keel/net/http" ) -// GetRequestHost returns the request's host +// GetRequestHost returns the request's host, preferring the X-Forwarded-Host +// header, then [http.Request.Host] for non-absolute request URLs, and the URL +// host otherwise. The result may include a port. func GetRequestHost(r *http.Request) string { if value := r.Header.Get(keelhttp.HeaderXForwardedHost); value != "" { return value @@ -19,6 +21,10 @@ func GetRequestHost(r *http.Request) string { } } +// GetRemoteAddr returns the client IP address of r. It prefers the +// X-Real-IP, True-Client-Ip and the first X-Forwarded-For entry, in that order, +// and falls back to the host part of [http.Request.RemoteAddr]. These headers +// are client-controlled unless set by a trusted proxy. func GetRemoteAddr(r *http.Request) string { if value := r.Header.Get(keelhttp.HeaderXRealIP); value != "" { return value @@ -37,7 +43,8 @@ func GetRemoteAddr(r *http.Request) string { } } -// GetRequestDomain returns the request's domain +// GetRequestDomain returns the host returned by [GetRequestHost] with any +// port removed. func GetRequestDomain(r *http.Request) string { domain := GetRequestHost(r) // right trim port