From a65786609380ab674075a52a13c24bd733f0963b Mon Sep 17 00:00:00 2001 From: gi8 Date: Mon, 19 Jan 2026 09:58:33 +0100 Subject: [PATCH 1/4] refactor --- internal/app/run.go | 26 ++- internal/debugserver/debugserver.go | 13 +- internal/debugserver/debugserver_test.go | 10 +- internal/handlers/api.go | 66 +++++++ internal/handlers/bump.go | 29 ++- internal/handlers/bump_test.go | 68 ++++--- internal/handlers/healthz.go | 12 +- internal/handlers/healthz_test.go | 11 +- internal/handlers/home.go | 9 +- internal/handlers/home_test.go | 13 +- internal/handlers/metrics.go | 18 +- internal/handlers/metrics_test.go | 9 +- internal/handlers/partials.go | 26 +-- internal/handlers/partials_test.go | 20 ++- internal/handlers/test.go | 26 ++- internal/handlers/test_test.go | 36 ++-- internal/handlers/utils.go | 26 +++ internal/heartbeat/actor.go | 18 +- internal/heartbeat/actor_test.go | 34 ++-- internal/heartbeat/manager.go | 4 +- internal/heartbeat/manager_test.go | 32 ++-- internal/heartbeat/utils.go | 9 +- internal/heartbeat/utils_test.go | 11 +- internal/history/events.go | 10 +- internal/history/events_test.go | 32 +--- internal/history/queries.go | 27 +++ internal/history/ringstore_test.go | 2 +- internal/metrics/metrics.go | 66 ++++--- internal/metrics/metrics_test.go | 36 ++-- internal/notifier/dispatcher.go | 35 ++-- internal/notifier/dispatcher_test.go | 12 +- internal/server/routes.go | 24 ++- internal/server/routes_test.go | 20 ++- internal/service/bump/bump.go | 22 +-- internal/service/bump/bump_test.go | 37 ++-- internal/service/health/status.go | 19 ++ internal/service/history/event.go | 214 +++++++++++++++++++++++ internal/service/test/test_test.go | 9 +- internal/view/partials.go | 14 +- internal/view/partials_test.go | 8 +- 40 files changed, 761 insertions(+), 352 deletions(-) create mode 100644 internal/handlers/api.go create mode 100644 internal/handlers/utils.go create mode 100644 internal/history/queries.go create mode 100644 internal/service/health/status.go create mode 100644 internal/service/history/event.go diff --git a/internal/app/run.go b/internal/app/run.go index 093532c..b88feb9 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -12,11 +12,13 @@ import ( "github.com/containeroo/heartbeats/internal/config" "github.com/containeroo/heartbeats/internal/debugserver" "github.com/containeroo/heartbeats/internal/flag" + "github.com/containeroo/heartbeats/internal/handlers" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/logging" "github.com/containeroo/heartbeats/internal/notifier" "github.com/containeroo/heartbeats/internal/server" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/containeroo/tinyflags" ) @@ -56,10 +58,11 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string } // Create history cache - history, err := history.InitializeHistory(flags) + histStore, err := history.InitializeHistory(flags) if err != nil { return fmt.Errorf("failed to initialize history: %w", err) } + histRecorder := servicehistory.NewRecorder(histStore) // Inizalize notification store := notifier.InitializeStore(cfg.Receivers, flags.SkipTLS, version, logger) @@ -67,7 +70,7 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string dispatcher := notifier.NewDispatcher( store, logger, - history, + histRecorder, flags.RetryCount, flags.RetryDelay, bufferSize, @@ -79,13 +82,24 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string ctx, cfg.Heartbeats, dispatcher.Mailbox(), - history, + histRecorder, logger, ) + api := handlers.NewAPI( + version, + commit, + webFS, + logger, + mgr, + histStore, + histRecorder, + dispatcher, + ) + // Run debug server if enabled if flags.Debug { - debugserver.Run(ctx, flags.DebugServerPort, mgr, dispatcher, logger) + debugserver.Run(ctx, flags.DebugServerPort, api) } // Create server and run forever @@ -94,9 +108,7 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string flags.SiteRoot, flags.RoutePrefix, version, - mgr, - history, - dispatcher, + api, logger, flags.Debug, ) diff --git a/internal/debugserver/debugserver.go b/internal/debugserver/debugserver.go index b77eb8f..8dd088e 100644 --- a/internal/debugserver/debugserver.go +++ b/internal/debugserver/debugserver.go @@ -3,23 +3,20 @@ package debugserver import ( "context" "fmt" - "log/slog" "net/http" "github.com/containeroo/heartbeats/internal/handlers" - "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/notifier" ) // Run starts the local-only debug server for manual testing. -func Run(ctx context.Context, port int, mgr *heartbeat.Manager, dispatcher *notifier.Dispatcher, logger *slog.Logger) { +func Run(ctx context.Context, port int, api *handlers.API) { mux := http.NewServeMux() - mux.Handle("GET /internal/receiver/{id}", handlers.TestReceiverHandler(dispatcher, logger)) - mux.Handle("GET /internal/heartbeat/{id}", handlers.TestHeartbeatHandler(mgr, logger)) + mux.Handle("GET /internal/receiver/{id}", api.TestReceiverHandler()) + mux.Handle("GET /internal/heartbeat/{id}", api.TestHeartbeatHandler()) addr := fmt.Sprintf("127.0.0.1:%d", port) - logger.Info("starting debug server", "listenAddr", addr) + api.Logger.Info("starting debug server", "listenAddr", addr) server := &http.Server{ Addr: addr, @@ -35,7 +32,7 @@ func Run(ctx context.Context, port int, mgr *heartbeat.Manager, dispatcher *noti // Serve requests on 127.0.0.1 until shutdown. go func() { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("debug server error", "error", err) + api.Logger.Error("debug server error", "error", err) } }() } diff --git a/internal/debugserver/debugserver_test.go b/internal/debugserver/debugserver_test.go index ce7be29..477cab2 100644 --- a/internal/debugserver/debugserver_test.go +++ b/internal/debugserver/debugserver_test.go @@ -8,9 +8,11 @@ import ( "testing" "time" + "github.com/containeroo/heartbeats/internal/handlers" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/require" ) @@ -22,8 +24,9 @@ func TestDebugServer_Run(t *testing.T) { // Setup basic in-memory state logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(5) + recorder := servicehistory.NewRecorder(hist) store := notifier.NewReceiverStore() - disp := notifier.NewDispatcher(store, logger, hist, 1, 1*time.Millisecond, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1*time.Millisecond, 10, nil) hbCfg := map[string]heartbeat.HeartbeatConfig{ "test-hb": { @@ -33,11 +36,12 @@ func TestDebugServer_Run(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, hbCfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, hbCfg, disp.Mailbox(), recorder, logger, nil) + api := handlers.NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) // Pick a random free port port := 8089 - Run(ctx, port, mgr, disp, logger) + Run(ctx, port, api) // Wait for server to start time.Sleep(100 * time.Millisecond) diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..585415d --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "io/fs" + "log/slog" + "net/http" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" + "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" +) + +// API bundles shared handler dependencies and runtime configuration. +type API struct { + Version string // Version is the build version string. + Commit string // Commit is the build commit SHA. + webFS fs.FS + Logger *slog.Logger + mgr *heartbeat.Manager + hist history.Store + rec *servicehistory.Recorder + disp *notifier.Dispatcher + metrics *metrics.Registry +} + +// NewAPI builds a handler container with shared dependencies. +func NewAPI( + version, commit string, + webFS fs.FS, + logger *slog.Logger, + mgr *heartbeat.Manager, + hist history.Store, + rec *servicehistory.Recorder, + disp *notifier.Dispatcher, +) *API { + return &API{ + Version: version, + Commit: commit, + webFS: webFS, + Logger: logger, + mgr: mgr, + hist: hist, + rec: rec, + disp: disp, + metrics: metrics.New(hist), + } +} + +// respondJSON writes a JSON response. +func (a *API) respondJSON(w http.ResponseWriter, status int, v any) { + if err := encode(w, status, v); err != nil { + a.Logger.Error("encode response failed", "err", err) + } +} + +// statusResponse is the standard success payload. +type statusResponse struct { + Status string `json:"status"` +} + +// errorResponse is the standard error payload. +type errorResponse struct { + Error string `json:"error"` +} diff --git a/internal/handlers/bump.go b/internal/handlers/bump.go index 46baa32..ee4cc9a 100644 --- a/internal/handlers/bump.go +++ b/internal/handlers/bump.go @@ -3,56 +3,51 @@ package handlers import ( "errors" "fmt" - "log/slog" "net/http" - "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/service/bump" ) // BumpHandler handles POST/GET /heartbeat/{id}. -func BumpHandler(mgr *heartbeat.Manager, hist history.Store, logger *slog.Logger) http.Handler { +func (a *API) BumpHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - http.Error(w, "missing id", http.StatusBadRequest) + a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"}) return } - if err := bump.Receive(r.Context(), mgr, hist, logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { + if err := bump.Receive(r.Context(), a.mgr, a.rec, a.Logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { if errors.Is(err, bump.ErrUnknownHeartbeat) { - http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) + a.respondJSON(w, http.StatusNotFound, errorResponse{Error: fmt.Sprintf("unknown heartbeat id %q", id)}) return } - http.Error(w, err.Error(), http.StatusInternalServerError) + a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()}) return } - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "ok") // nolint:errcheck + a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"}) }) } // FailHandler handles POST/GET /heartbeat/{id}/fail. -func FailHandler(mgr *heartbeat.Manager, hist history.Store, logger *slog.Logger) http.Handler { +func (a *API) FailHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - http.Error(w, "missing id", http.StatusBadRequest) + a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"}) return } - if err := bump.Fail(r.Context(), mgr, hist, logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { + if err := bump.Fail(r.Context(), a.mgr, a.rec, a.Logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { if errors.Is(err, bump.ErrUnknownHeartbeat) { - http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) + a.respondJSON(w, http.StatusNotFound, errorResponse{Error: fmt.Sprintf("unknown heartbeat id %q", id)}) return } - http.Error(w, err.Error(), http.StatusInternalServerError) + a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()}) return } - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "ok") // nolint:errcheck + a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"}) }) } diff --git a/internal/handlers/bump_test.go b/internal/handlers/bump_test.go index 2e33633..fbc14c0 100644 --- a/internal/handlers/bump_test.go +++ b/internal/handlers/bump_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -14,6 +15,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -32,15 +34,17 @@ func setupRouter(t *testing.T, hbName string, hist history.Store) http.Handler { } store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), heartbeats, disp.Mailbox(), hist, logger) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), heartbeats, disp.Mailbox(), recorder, logger, nil) + api := NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) router := http.NewServeMux() - router.Handle("GET /no-id/", BumpHandler(mgr, hist, logger)) - router.Handle("GET /no-id/fail", FailHandler(mgr, hist, logger)) - router.Handle("GET /bump/{id}", BumpHandler(mgr, hist, logger)) - router.Handle("POST /bump/{id}", BumpHandler(mgr, hist, logger)) - router.Handle("GET /bump/{id}/fail", FailHandler(mgr, hist, logger)) - router.Handle("POST /bump/{id}/fail", FailHandler(mgr, hist, logger)) + router.Handle("GET /no-id/", api.BumpHandler()) + router.Handle("GET /no-id/fail", api.FailHandler()) + router.Handle("GET /bump/{id}", api.BumpHandler()) + router.Handle("POST /bump/{id}", api.BumpHandler()) + router.Handle("GET /bump/{id}/fail", api.FailHandler()) + router.Handle("POST /bump/{id}/fail", api.FailHandler()) return router } @@ -59,7 +63,9 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Equal(t, "missing id\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "missing id", resp.Error) }) t.Run("not found id", func(t *testing.T) { @@ -72,7 +78,9 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) - assert.Equal(t, "unknown heartbeat id \"not-found\"\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "unknown heartbeat id \"not-found\"", resp.Error) }) t.Run("successful bump GET", func(t *testing.T) { @@ -89,7 +97,9 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) e := hist.ListByID(hbName) @@ -98,7 +108,7 @@ func TestBumpHandler(t *testing.T) { assert.Equal(t, hbName, ev.HeartbeatID) var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "GET", meta.Method) assert.Equal(t, "1.2.3.4:5678", meta.Source) assert.Equal(t, "Go-test", meta.UserAgent) @@ -117,7 +127,9 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) e := hist.ListByID(hbName) ev := e[0] @@ -126,7 +138,7 @@ func TestBumpHandler(t *testing.T) { assert.Equal(t, hbName, ev.HeartbeatID) var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "POST", meta.Method) }) } @@ -146,7 +158,9 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Equal(t, "missing id\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "missing id", resp.Error) }) t.Run("not found id", func(t *testing.T) { @@ -159,7 +173,9 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) - assert.Equal(t, "unknown heartbeat id \"not-found\"\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "unknown heartbeat id \"not-found\"", resp.Error) }) t.Run("successful fail GET", func(t *testing.T) { @@ -176,7 +192,9 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) events := hist.ListByID(hbName) assert.Len(t, events, 1) @@ -186,7 +204,7 @@ func TestFailHandler(t *testing.T) { assert.Equal(t, hbName, ev.HeartbeatID) var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "GET", meta.Method) assert.Equal(t, "1.2.3.4:5678", meta.Source) @@ -206,7 +224,9 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) e := hist.ListByID(hbName) ev := e[0] @@ -215,7 +235,7 @@ func TestFailHandler(t *testing.T) { assert.Equal(t, hbName, ev.HeartbeatID) var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "POST", meta.Method) }) @@ -238,7 +258,9 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Equal(t, "fail!\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail!", resp.Error) }) t.Run("fail - record event error", func(t *testing.T) { @@ -260,6 +282,8 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Equal(t, "fail!\n", rec.Body.String()) + var resp domain.ErrorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail!", resp.Error) }) } diff --git a/internal/handlers/healthz.go b/internal/handlers/healthz.go index 9450679..f037212 100644 --- a/internal/handlers/healthz.go +++ b/internal/handlers/healthz.go @@ -1,11 +1,15 @@ package handlers -import "net/http" +import ( + "net/http" + + "github.com/containeroo/heartbeats/internal/service/health" +) // Healthz returns the HTTP handler for the /healthz endpoint. -func Healthz() http.HandlerFunc { +func (a *API) Healthz(service *health.Service) http.HandlerFunc { + status := service.Status() return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) + a.respondJSON(w, http.StatusOK, statusResponse{Status: status.Status}) } } diff --git a/internal/handlers/healthz_test.go b/internal/handlers/healthz_test.go index 5fb38bc..d5d27a1 100644 --- a/internal/handlers/healthz_test.go +++ b/internal/handlers/healthz_test.go @@ -1,17 +1,22 @@ package handlers import ( + "encoding/json" + "log/slog" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/containeroo/heartbeats/internal/service/health" "github.com/stretchr/testify/assert" ) func TestHealthz(t *testing.T) { t.Parallel() - handler := Healthz() + api := NewAPI("test", "test", nil, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + handler := api.Healthz(health.NewService()) req := httptest.NewRequest("GET", "/healthz", nil) rec := httptest.NewRecorder() @@ -19,5 +24,7 @@ func TestHealthz(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Expected status code 200") - assert.Equal(t, "ok", rec.Body.String(), "Expected response body 'ok'") + var resp statusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status, "Expected response status 'ok'") } diff --git a/internal/handlers/home.go b/internal/handlers/home.go index c502ce6..486fe39 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -2,16 +2,15 @@ package handlers import ( "html/template" - "io/fs" "net/http" "path" ) // HomeHandler renders the base template with navbar and footer. -func HomeHandler(webFS fs.FS, version string) http.HandlerFunc { +func (a *API) HomeHandler() http.HandlerFunc { tmpl := template.Must( template.New("base.html"). - ParseFS(webFS, + ParseFS(a.webFS, path.Join("web/templates", "base.html"), path.Join("web/templates", "navbar.html"), path.Join("web/templates", "footer.html"), @@ -21,13 +20,13 @@ func HomeHandler(webFS fs.FS, version string) http.HandlerFunc { Version string Commit string }{ - Version: version, + Version: a.Version, } return func(w http.ResponseWriter, r *http.Request) { // execute the "base" template if err := tmpl.ExecuteTemplate(w, "base", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()}) } } } diff --git a/internal/handlers/home_test.go b/internal/handlers/home_test.go index baea4e2..9b7d218 100644 --- a/internal/handlers/home_test.go +++ b/internal/handlers/home_test.go @@ -1,8 +1,11 @@ package handlers import ( + "encoding/json" + "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "testing/fstest" @@ -23,7 +26,8 @@ func TestHomeHandler(t *testing.T) { } version := "v1.2.3" - handler := HomeHandler(webFS, version) + api := NewAPI(version, "test", webFS, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + handler := api.HomeHandler() req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() @@ -52,7 +56,8 @@ func TestHomeHandler(t *testing.T) { } version := "v1.2.3" - handler := HomeHandler(webFS, version) + api := NewAPI(version, "test", webFS, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + handler := api.HomeHandler() req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() @@ -63,6 +68,8 @@ func TestHomeHandler(t *testing.T) { defer resp.Body.Close() // nolint:errcheck assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) - assert.Equal(t, "html/template: \"base\" is undefined\n", rec.Body.String()) + var payload errorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&payload)) + assert.Equal(t, "html/template: \"base\" is undefined", payload.Error) }) } diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index 86d5be3..88a81d6 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -1,18 +1,8 @@ package handlers -import ( - "net/http" +import "net/http" - "github.com/containeroo/heartbeats/internal/history" - "github.com/containeroo/heartbeats/internal/metrics" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// Metrics returns the HTTP handler for the /metrics endpoint. -func Metrics(hist history.Store) http.HandlerFunc { - metrics := metrics.NewMetrics(hist) - h := promhttp.HandlerFor(metrics.Registry, promhttp.HandlerOpts{}) - return func(w http.ResponseWriter, r *http.Request) { - h.ServeHTTP(w, r) - } +// Metrics exposes Prometheus metrics. +func (a *API) Metrics() http.Handler { + return a.metrics.Metrics() } diff --git a/internal/handlers/metrics_test.go b/internal/handlers/metrics_test.go index 39576cc..b912c67 100644 --- a/internal/handlers/metrics_test.go +++ b/internal/handlers/metrics_test.go @@ -2,11 +2,14 @@ package handlers import ( "context" + "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/stretchr/testify/assert" ) @@ -21,7 +24,9 @@ func TestMetricsHandler(t *testing.T) { } // Create metrics handler with injected store - handler := Metrics(store) + metricsReg := metrics.New(store) + api := NewAPI("test", "test", nil, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, store, nil, nil, metricsReg) + handler := api.Metrics() req := httptest.NewRequest("GET", "/metrics", nil) rec := httptest.NewRecorder() @@ -29,5 +34,5 @@ func TestMetricsHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Expected HTTP 200 OK") - assert.Equal(t, "# HELP heartbeats_history_byte_size Current size of the history store in bytes\n# TYPE heartbeats_history_byte_size gauge\nheartbeats_history_byte_size 8720\n", rec.Body.String(), "Expected metrics to include 'heartbeats_history_byte_size'") + assert.Contains(t, rec.Body.String(), "heartbeats_history_byte_size", "Expected metrics to include 'heartbeats_history_byte_size'") } diff --git a/internal/handlers/partials.go b/internal/handlers/partials.go index 750fef4..90336c1 100644 --- a/internal/handlers/partials.go +++ b/internal/handlers/partials.go @@ -1,32 +1,24 @@ package handlers import ( - "io/fs" - "log/slog" + "fmt" "net/http" "path" "strings" "text/template" - "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" "github.com/containeroo/heartbeats/internal/view" ) // PartialHandler serves HTML snippets for dashboard sections: heartbeats, receivers, history. -func PartialHandler( - webFS fs.FS, +func (a *API) PartialHandler( siteRoot string, - mgr *heartbeat.Manager, - hist history.Store, - disp *notifier.Dispatcher, - logger *slog.Logger, ) http.HandlerFunc { tmpl := template.Must( template.New("partials"). Funcs(notifier.FuncMap()). - ParseFS(webFS, + ParseFS(a.webFS, "web/templates/heartbeats.html", "web/templates/receivers.html", "web/templates/history.html", @@ -41,19 +33,19 @@ func PartialHandler( switch section { case "heartbeats": - err = view.RenderHeartbeats(w, tmpl, bumpURL, mgr, hist) + err = view.RenderHeartbeats(w, tmpl, bumpURL, a.mgr, a.hist) case "receivers": - err = view.RenderReceivers(w, tmpl, disp) + err = view.RenderReceivers(w, tmpl, a.disp) case "history": - err = view.RenderHistory(w, tmpl, hist) + err = view.RenderHistory(w, tmpl, a.hist) default: - http.NotFound(w, r) + a.respondJSON(w, http.StatusNotFound, errorResponse{Error: fmt.Sprintf("unknown partial %q", section)}) return } if err != nil { - logger.Error("render "+section+" partial", "error", err) - http.Error(w, "internal error", http.StatusInternalServerError) + a.Logger.Error("render "+section+" partial", "error", err) + a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal error"}) } } } diff --git a/internal/handlers/partials_test.go b/internal/handlers/partials_test.go index 6bd6af4..8c420d9 100644 --- a/internal/handlers/partials_test.go +++ b/internal/handlers/partials_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "log/slog" "net/http" "net/http/httptest" @@ -13,6 +14,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -37,7 +39,8 @@ func TestPartialHandler(t *testing.T) { }, )) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "hb1": { @@ -46,7 +49,8 @@ func TestPartialHandler(t *testing.T) { Grace: 5 * time.Second, Receivers: []string{"r1"}, }, - }, disp.Mailbox(), hist, logger) + }, disp.Mailbox(), recorder, logger, nil) + api := NewAPI("test", "test", webFS, logger, mgr, hist, recorder, disp, nil) t.Run("not found", func(t *testing.T) { t.Parallel() @@ -54,11 +58,13 @@ func TestPartialHandler(t *testing.T) { req := httptest.NewRequest("GET", "/partials/invalid", nil) rr := httptest.NewRecorder() - handler := PartialHandler(webFS, "http://localhost", mgr, hist, disp, logger) + handler := api.PartialHandler("http://localhost") handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) - assert.Equal(t, "404 page not found\n", rr.Body.String()) + var resp errorResponse + assert.NoError(t, json.NewDecoder(rr.Body).Decode(&resp)) + assert.Equal(t, "unknown partial \"invalid\"", resp.Error) }) t.Run("heartbeats", func(t *testing.T) { @@ -67,7 +73,7 @@ func TestPartialHandler(t *testing.T) { req := httptest.NewRequest("GET", "/partials/heartbeats", nil) rr := httptest.NewRecorder() - handler := PartialHandler(webFS, "http://localhost", mgr, hist, disp, logger) + handler := api.PartialHandler("http://localhost") handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) @@ -80,7 +86,7 @@ func TestPartialHandler(t *testing.T) { req := httptest.NewRequest("GET", "/partials/receivers", nil) rr := httptest.NewRecorder() - handler := PartialHandler(webFS, "http://localhost", mgr, hist, disp, logger) + handler := api.PartialHandler("http://localhost") handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) @@ -93,7 +99,7 @@ func TestPartialHandler(t *testing.T) { req := httptest.NewRequest("GET", "/partials/history", nil) rr := httptest.NewRecorder() - handler := PartialHandler(webFS, "http://localhost", mgr, hist, disp, logger) + handler := api.PartialHandler("http://localhost") handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) diff --git a/internal/handlers/test.go b/internal/handlers/test.go index bb86df9..0065359 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -1,47 +1,41 @@ package handlers import ( - "fmt" - "log/slog" "net/http" - "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/notifier" testservice "github.com/containeroo/heartbeats/internal/service/test" ) // TestReceiverHandler allows sending a test notification to a specific receiver -func TestReceiverHandler(dispatcher *notifier.Dispatcher, logger *slog.Logger) http.Handler { +func (a *API) TestReceiverHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - http.Error(w, "missing id", http.StatusBadRequest) + a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"}) return } - testservice.SendTestNotification(dispatcher, logger, id) + testservice.SendTestNotification(a.disp, a.Logger, id) - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "ok") // nolint:errcheck + a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"}) }) } // TestHeartbeatHandler allows sending a test notification to a specific heartbeat -func TestHeartbeatHandler(mgr *heartbeat.Manager, logger *slog.Logger) http.Handler { +func (a *API) TestHeartbeatHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { - http.Error(w, "missing id", http.StatusBadRequest) + a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"}) return } - if err := testservice.TriggerTestHeartbeat(mgr, logger, id); err != nil { - logger.Error("handle test failed", "id", id, "err", err) - http.Error(w, err.Error(), http.StatusNotFound) + if err := testservice.TriggerTestHeartbeat(a.mgr, a.Logger, id); err != nil { + a.Logger.Error("handle test failed", "id", id, "err", err) + a.respondJSON(w, http.StatusNotFound, errorResponse{Error: err.Error()}) return } - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "ok") // nolint:errcheck + a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"}) }) } diff --git a/internal/handlers/test_test.go b/internal/handlers/test_test.go index 9b6f933..f3cbfd7 100644 --- a/internal/handlers/test_test.go +++ b/internal/handlers/test_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "fmt" "log/slog" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -22,9 +24,11 @@ func TestTestReceiverHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10) + api := NewAPI("test", "test", nil, logger, nil, hist, recorder, disp) - handler := TestReceiverHandler(disp, logger) + handler := api.TestReceiverHandler() t.Run("missing id", func(t *testing.T) { t.Parallel() @@ -37,7 +41,9 @@ func TestTestReceiverHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Equal(t, "missing id\n", rec.Body.String()) + var resp errorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "missing id", resp.Error) }) t.Run("sends test notification", func(t *testing.T) { @@ -50,7 +56,9 @@ func TestTestReceiverHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp statusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) }) } @@ -60,7 +68,8 @@ func TestTestHeartbeatHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10) hbName := "test-hb" cfg := heartbeat.HeartbeatConfigMap{ @@ -72,8 +81,9 @@ func TestTestHeartbeatHandler(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), hist, logger) - handler := TestHeartbeatHandler(mgr, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger) + api := NewAPI(logger, "test", "test", nil, mgr, hist, recorder, disp) + handler := api.TestHeartbeatHandler() t.Run("missing id", func(t *testing.T) { t.Parallel() @@ -85,7 +95,9 @@ func TestTestHeartbeatHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - assert.Equal(t, "missing id\n", rec.Body.String()) + var resp errorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "missing id", resp.Error) }) t.Run("unknown id", func(t *testing.T) { @@ -98,7 +110,9 @@ func TestTestHeartbeatHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) - assert.Equal(t, "heartbeat ID \"invalid\" not found\n", rec.Body.String()) + var resp errorResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "heartbeat ID \"invalid\" not found", resp.Error) }) t.Run("trigger test heartbeat", func(t *testing.T) { @@ -111,6 +125,8 @@ func TestTestHeartbeatHandler(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp statusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) }) } diff --git a/internal/handlers/utils.go b/internal/handlers/utils.go new file mode 100644 index 0000000..7c9b507 --- /dev/null +++ b/internal/handlers/utils.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// encode encodes a value to JSON and writes it to the response. +func encode[T any](w http.ResponseWriter, status int, v T) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil +} + +// decode decodes a value from JSON and returns it. +func decode[T any](r *http.Request) (T, error) { + var v T + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + return v, fmt.Errorf("decode json: %w", err) + } + return v, nil +} diff --git a/internal/heartbeat/actor.go b/internal/heartbeat/actor.go index 85aacf1..809ba27 100644 --- a/internal/heartbeat/actor.go +++ b/internal/heartbeat/actor.go @@ -6,10 +6,9 @@ import ( "time" "github.com/containeroo/heartbeats/internal/common" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" - "github.com/prometheus/client_golang/prometheus" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // transitionDelay adds buffer before state transitions to absorb late pings. @@ -25,8 +24,9 @@ type Actor struct { Receivers []string // list of receiver IDs to notify upon alerts mailbox chan common.EventType // incoming event channel for this actor logger *slog.Logger // structured logger scoped to this actor - hist history.Store // history store for recording events + hist *servicehistory.Recorder // history store for recording events dispatchCh chan<- notifier.NotificationData // sends notifications to the dispatcher + metrics *metrics.Registry // metrics registry LastBump time.Time // timestamp of the last received heartbeat checkTimer *time.Timer // timer waiting for the next heartbeat graceTimer *time.Timer // timer for the grace period countdown @@ -44,8 +44,9 @@ type ActorConfig struct { Grace time.Duration // grace period after a missed ping before triggering an alert Receivers []string // list of receiver IDs to notify on failure Logger *slog.Logger // logger scoped to this actor - History history.Store // store to persist heartbeat and notification events + History *servicehistory.Recorder // store to persist heartbeat and notification events DispatchCh chan<- notifier.NotificationData // channel to send notifications through the dispatcher + Metrics *metrics.Registry // metrics registry } // NewActorFromConfig creates a new heartbeat actor. @@ -61,6 +62,7 @@ func NewActorFromConfig(cfg ActorConfig) *Actor { logger: cfg.Logger, hist: cfg.History, dispatchCh: cfg.DispatchCh, + metrics: cfg.Metrics, State: common.HeartbeatStateIdle, } } @@ -146,8 +148,8 @@ func (a *Actor) onReceive() { a.LastBump = now a.checkTimer = time.NewTimer(a.Interval) - metrics.ReceivedTotal.With(prometheus.Labels{"heartbeat": a.ID}).Inc() - metrics.LastStatus.With(prometheus.Labels{"heartbeat": a.ID}).Set(metrics.UP) + a.metrics.IncHeartbeatReceived(a.ID) + a.metrics.SetHeartbeatStatus(a.ID, metrics.UP) } // onFail handles a manual failure trigger. @@ -173,7 +175,7 @@ func (a *Actor) onFail() { a.logger.Error("failed to record state change", "err", err) } - metrics.LastStatus.With(prometheus.Labels{"heartbeat": a.ID}).Set(metrics.DOWN) + a.metrics.SetHeartbeatStatus(a.ID, metrics.DOWN) } // onEnterGrace transitions to Grace state. @@ -211,7 +213,7 @@ func (a *Actor) onEnterMissing() { Receivers: a.Receivers, } - metrics.LastStatus.With(prometheus.Labels{"heartbeat": a.ID}).Set(metrics.DOWN) + a.metrics.SetHeartbeatStatus(a.ID, metrics.DOWN) } // onTest sends only a notification to the Actor without changing state. diff --git a/internal/heartbeat/actor_test.go b/internal/heartbeat/actor_test.go index 5329b7c..3058458 100644 --- a/internal/heartbeat/actor_test.go +++ b/internal/heartbeat/actor_test.go @@ -2,6 +2,7 @@ package heartbeat import ( "context" + "encoding/json" "errors" "log/slog" "slices" @@ -12,6 +13,7 @@ import ( "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -23,7 +25,7 @@ func waitForPayloadEvent[T any](t *testing.T, hist history.Store, match func(T) for time.Now().Before(deadline) { for _, ev := range hist.List() { var payload T - if err := ev.DecodePayload(&payload); err != nil { + if len(ev.RawPayload) == 0 || json.Unmarshal(ev.RawPayload, &payload) != nil { continue // skip non-matching or invalid payloads } if match(payload) { @@ -67,7 +69,7 @@ func TestActor_Run_Smoke(t *testing.T) { }, }) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, nil) go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ @@ -78,7 +80,7 @@ func TestActor_Run_Smoke(t *testing.T) { Grace: 100 * time.Millisecond, Receivers: []string{"r1"}, Logger: logger, - History: hist, + History: servicehistory.NewRecorder(hist), DispatchCh: disp.Mailbox(), }) go actor.Run(ctx) @@ -119,7 +121,7 @@ func TestActor_Run_Smoke(t *testing.T) { return nil }, }) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, nil) go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ @@ -130,7 +132,7 @@ func TestActor_Run_Smoke(t *testing.T) { Grace: 100 * time.Millisecond, Receivers: []string{"r1"}, Logger: logger, - History: hist, + History: servicehistory.NewRecorder(hist), DispatchCh: disp.Mailbox(), }) go actor.Run(ctx) @@ -195,7 +197,7 @@ func TestActor_OnReceive(t *testing.T) { Grace: 50 * time.Millisecond, Receivers: []string{"r1"}, logger: logger, - hist: hist, + hist: servicehistory.NewRecorder(hist), dispatchCh: dispatchCh, State: common.HeartbeatStateMissing, } @@ -234,7 +236,7 @@ func TestActor_OnReceive(t *testing.T) { Grace: 50 * time.Millisecond, Receivers: []string{"r2"}, logger: logger, - hist: hist, + hist: servicehistory.NewRecorder(hist), dispatchCh: dispatchCh, State: common.HeartbeatStateIdle, } @@ -278,7 +280,7 @@ func TestActor_OnReceive(t *testing.T) { Grace: 50 * time.Millisecond, Receivers: []string{"r2"}, logger: logger, - hist: &mockHist, + hist: servicehistory.NewRecorder(&mockHist), dispatchCh: dispatchCh, State: common.HeartbeatStateIdle, } @@ -306,7 +308,7 @@ func TestActor_OnFail(t *testing.T) { Receivers: []string{"r1"}, State: common.HeartbeatStateActive, dispatchCh: recv, - hist: hist, + hist: servicehistory.NewRecorder(hist), logger: logger, } @@ -348,7 +350,7 @@ func TestActor_OnFail(t *testing.T) { Grace: 50 * time.Millisecond, Receivers: []string{"r2"}, logger: logger, - hist: &mockHist, + hist: servicehistory.NewRecorder(&mockHist), dispatchCh: dispatchCh, State: common.HeartbeatStateActive, } @@ -374,7 +376,7 @@ func TestActor_OnEnterGrace(t *testing.T) { State: common.HeartbeatStateActive, Grace: 50 * time.Millisecond, logger: logger, - hist: hist, + hist: servicehistory.NewRecorder(hist), mailbox: make(chan common.EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), } @@ -396,7 +398,7 @@ func TestActor_OnEnterGrace(t *testing.T) { State: common.HeartbeatStateIdle, Grace: 50 * time.Millisecond, logger: logger, - hist: hist, + hist: servicehistory.NewRecorder(hist), mailbox: make(chan common.EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), } @@ -424,7 +426,7 @@ func TestActor_OnEnterGrace(t *testing.T) { State: common.HeartbeatStateActive, Grace: 50 * time.Millisecond, logger: logger, - hist: &mockHist, + hist: servicehistory.NewRecorder(&mockHist), mailbox: make(chan common.EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), } @@ -455,7 +457,7 @@ func TestActor_OnEnterMissing(t *testing.T) { LastBump: now, Receivers: []string{"r1"}, dispatchCh: recv, - hist: hist, + hist: servicehistory.NewRecorder(hist), logger: logger, } @@ -486,7 +488,7 @@ func TestActor_OnEnterMissing(t *testing.T) { ID: "x", State: common.HeartbeatStateActive, dispatchCh: recv, - hist: hist, + hist: servicehistory.NewRecorder(hist), logger: logger, } @@ -517,7 +519,7 @@ func TestActor_OnEnterMissing(t *testing.T) { ID: "x", State: common.HeartbeatStateGrace, dispatchCh: make(chan notifier.NotificationData, 1), - hist: &mockHist, + hist: servicehistory.NewRecorder(&mockHist), logger: logger, } diff --git a/internal/heartbeat/manager.go b/internal/heartbeat/manager.go index 6db7a1d..16c5617 100644 --- a/internal/heartbeat/manager.go +++ b/internal/heartbeat/manager.go @@ -6,8 +6,8 @@ import ( "log/slog" "github.com/containeroo/heartbeats/internal/common" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // Manager routes HTTP pings to Actors. @@ -21,7 +21,7 @@ func NewManagerFromHeartbeatMap( ctx context.Context, heartbeatConfigs HeartbeatConfigMap, dispatchCh chan<- notifier.NotificationData, - hist history.Store, + hist *servicehistory.Recorder, logger *slog.Logger, ) *Manager { m := &Manager{actors: make(map[string]*Actor, len(heartbeatConfigs)), logger: logger} diff --git a/internal/heartbeat/manager_test.go b/internal/heartbeat/manager_test.go index 55fae9d..26f9d5b 100644 --- a/internal/heartbeat/manager_test.go +++ b/internal/heartbeat/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -20,8 +21,9 @@ func TestManager_HandleReceive(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) t.Run("sends receive to known actor", func(t *testing.T) { t.Parallel() @@ -35,7 +37,7 @@ func TestManager_HandleReceive(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Receive("a1") assert.NoError(t, err) @@ -49,7 +51,7 @@ func TestManager_HandleReceive(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Receive("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -62,8 +64,9 @@ func TestManager_HandleFail(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) t.Run("sends fail to known actor", func(t *testing.T) { t.Parallel() @@ -77,7 +80,7 @@ func TestManager_HandleFail(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Fail("a1") assert.NoError(t, err) @@ -91,7 +94,7 @@ func TestManager_HandleFail(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Fail("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -104,8 +107,9 @@ func TestManager_HandleTest(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) t.Run("sends test event to known actor", func(t *testing.T) { t.Parallel() @@ -119,7 +123,7 @@ func TestManager_HandleTest(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Test("a1") assert.NoError(t, err) @@ -132,7 +136,7 @@ func TestManager_HandleTest(t *testing.T) { t.Parallel() cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) err := mgr.Test("does-not-exist") assert.Error(t, err) @@ -146,8 +150,9 @@ func TestManager_Get(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 0, 0, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) cfg := map[string]heartbeat.HeartbeatConfig{ "a1": { @@ -164,7 +169,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) result := mgr.List() assert.Len(t, result, 2) @@ -176,8 +181,9 @@ func TestManager_List(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, hist, 0, 0, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) cfg := map[string]heartbeat.HeartbeatConfig{ "a1": { @@ -194,7 +200,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) t.Run("Get found", func(t *testing.T) { t.Parallel() diff --git a/internal/heartbeat/utils.go b/internal/heartbeat/utils.go index 3c4a9d2..09d7bb0 100644 --- a/internal/heartbeat/utils.go +++ b/internal/heartbeat/utils.go @@ -4,7 +4,7 @@ import ( "time" "github.com/containeroo/heartbeats/internal/common" - "github.com/containeroo/heartbeats/internal/history" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // stopTimer stops a running timer and ensures its channel is drained. @@ -55,11 +55,8 @@ func (a *Actor) recordStateChange(prev, next common.HeartbeatState) error { "to", to, ) - payload := history.StateChangePayload{ - From: from, - To: to, - } - ev := history.MustNewEvent(history.EventTypeStateChanged, a.ID, payload) + factory := servicehistory.NewFactory() + ev := factory.StateChanged(a.ID, from, to) return a.hist.Append(a.ctx, ev) } diff --git a/internal/heartbeat/utils_test.go b/internal/heartbeat/utils_test.go index c5beb02..50103a5 100644 --- a/internal/heartbeat/utils_test.go +++ b/internal/heartbeat/utils_test.go @@ -10,6 +10,7 @@ import ( "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/history" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -61,12 +62,13 @@ func TestRecordStateChange_ChangesState(t *testing.T) { var logBuf strings.Builder logger := slog.New(slog.NewTextHandler(&logBuf, nil)) hist := history.NewRingStore(10) + recorder := servicehistory.NewRecorder(hist) a := &Actor{ ID: "demo", ctx: context.Background(), logger: logger, - hist: hist, + hist: recorder, } a.recordStateChange(common.HeartbeatStateGrace, common.HeartbeatStateActive) // nolint:errcheck @@ -82,12 +84,13 @@ func TestRecordStateChange_ChangesState(t *testing.T) { var logBuf strings.Builder logger := slog.New(slog.NewTextHandler(&logBuf, nil)) hist := history.NewRingStore(10) + recorder := servicehistory.NewRecorder(hist) a := &Actor{ ID: "noop", ctx: context.Background(), logger: logger, - hist: hist, + hist: recorder, } a.recordStateChange(common.HeartbeatStateActive, common.HeartbeatStateActive) // nolint:errcheck @@ -105,11 +108,13 @@ func TestRecordStateChange_ChangesState(t *testing.T) { }, } + recorder := servicehistory.NewRecorder(mockHist) + a := &Actor{ ID: "noop", ctx: context.Background(), logger: logger, - hist: mockHist, + hist: recorder, } a.recordStateChange(common.HeartbeatStateActive, common.HeartbeatStateActive) // nolint:errcheck diff --git a/internal/history/events.go b/internal/history/events.go index 75fbd66..5bcaf03 100644 --- a/internal/history/events.go +++ b/internal/history/events.go @@ -25,6 +25,7 @@ type Event struct { Timestamp time.Time `json:"timestamp"` // when it happened Type EventType `json:"type"` // what kind of event HeartbeatID string `json:"heartbeat_id"` // which heartbeat it belongs to + Version int `json:"version"` // payload schema version RawPayload json.RawMessage `json:"payload,omitempty"` // optional payload } @@ -51,6 +52,7 @@ func NewEvent(t EventType, id string, payload any) (Event, error) { Timestamp: time.Now(), Type: t, HeartbeatID: id, + Version: 1, RawPayload: raw, }, nil } @@ -63,11 +65,3 @@ func MustNewEvent(t EventType, id string, payload any) Event { } return ev } - -// DecodePayload unmarshals the raw payload into the given target. -func (e *Event) DecodePayload(v any) error { - if len(e.RawPayload) == 0 { - return fmt.Errorf("empty payload") - } - return json.Unmarshal(e.RawPayload, v) -} diff --git a/internal/history/events_test.go b/internal/history/events_test.go index 309dc26..539a931 100644 --- a/internal/history/events_test.go +++ b/internal/history/events_test.go @@ -29,9 +29,10 @@ func TestNewEvent(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "HeartbeatReceived", ev.Type.String()) assert.Equal(t, "hb1", ev.HeartbeatID) + assert.Equal(t, 1, ev.Version) var dp dummyPayload - assert.NoError(t, ev.DecodePayload(&dp)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &dp)) assert.Equal(t, "ping", dp.Message) }) @@ -39,6 +40,7 @@ func TestNewEvent(t *testing.T) { ev, err := NewEvent(EventTypeNotificationSent, "hb2", nil) assert.NoError(t, err) assert.Nil(t, ev.RawPayload) + assert.Equal(t, 1, ev.Version) }) t.Run("invalid payload", func(t *testing.T) { @@ -73,31 +75,3 @@ func TestEvent_ToJSON(t *testing.T) { assert.Equal(t, "", ev.ToJSON()) }) } - -func TestEvent_DecodePayload(t *testing.T) { - t.Parallel() - - t.Run("decodes valid payload", func(t *testing.T) { - ev := MustNewEvent(EventTypeNotificationSent, "hbZ", dummyPayload{Message: "hello"}) - var dp dummyPayload - err := ev.DecodePayload(&dp) - assert.NoError(t, err) - assert.Equal(t, "hello", dp.Message) - }) - - t.Run("errors on empty payload", func(t *testing.T) { - ev := Event{} - var dp dummyPayload - err := ev.DecodePayload(&dp) - assert.EqualError(t, err, "empty payload") - }) - - t.Run("errors on invalid JSON", func(t *testing.T) { - ev := Event{ - RawPayload: json.RawMessage(`{invalid-json}`), - } - var dp dummyPayload - err := ev.DecodePayload(&dp) - assert.Error(t, err) - }) -} diff --git a/internal/history/queries.go b/internal/history/queries.go new file mode 100644 index 0000000..267da55 --- /dev/null +++ b/internal/history/queries.go @@ -0,0 +1,27 @@ +package history + +import "sort" + +// ListRecent returns events sorted newest-first. If limit <= 0, returns all. +func ListRecent(store Store, limit int) []Event { + events := store.List() + sort.Slice(events, func(i, j int) bool { + return events[j].Timestamp.Before(events[i].Timestamp) + }) + if limit > 0 && len(events) > limit { + return events[:limit] + } + return events +} + +// ListByType returns events matching the given type. +func ListByType(store Store, t EventType) []Event { + events := store.List() + filtered := make([]Event, 0, len(events)) + for _, e := range events { + if e.Type == t { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/internal/history/ringstore_test.go b/internal/history/ringstore_test.go index 971a515..e3a40f0 100644 --- a/internal/history/ringstore_test.go +++ b/internal/history/ringstore_test.go @@ -135,7 +135,7 @@ func TestRingStore_ByteSize(t *testing.T) { } got := store.ByteSize() - assert.Equal(t, 1730000, got) + assert.Equal(t, 1810000, got) } func TestRingStore_ByteSizePerformance(t *testing.T) { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 31d2c8f..a79a92a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,8 +1,11 @@ package metrics import ( + "net/http" + "github.com/containeroo/heartbeats/internal/history" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" ) // heartbeats_heartbeat_last_status @@ -17,53 +20,66 @@ const ( ERROR float64 = 1 ) -var ( - // LastStatus reports the most recent status of each heartbeat (0 = DOWN, 1 = UP). - LastStatus = prometheus.NewGaugeVec( +// Registry holds Prometheus metrics for the app. +type Registry struct { + registry *prometheus.Registry + lastStatus *prometheus.GaugeVec + receivedTotal *prometheus.CounterVec + receiverLastStatus *prometheus.GaugeVec +} + +// New builds a new Prometheus metrics registry. +func New(store history.Store) *Registry { + lastStatus := prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "heartbeats_heartbeat_last_status", Help: "Most recent status of each heartbeat (0 = DOWN, 1 = UP)", }, []string{"heartbeat"}, ) - - // ReceivedTotal counts the total number of received heartbeats per ID. - ReceivedTotal = prometheus.NewCounterVec( + receivedTotal := prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "heartbeats_heartbeat_received_total", Help: "Total number of received heartbeats per ID", }, []string{"heartbeat"}, ) - - // ReceiverErrorStatus reports the status of the last notification attempt (1 = ERROR, 0 = SUCCESS) - ReceiverErrorStatus = prometheus.NewGaugeVec( + receiverLastStatus := prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "heartbeats_receiver_last_status", Help: "Reports the status of the last notification attempt (1 = ERROR, 0 = SUCCESS)", }, []string{"receiver", "type", "target"}, ) -) -// Metrics wraps a Prometheus registry and provides functionality for metric registration. -type Metrics struct { - Registry *prometheus.Registry + reg := prometheus.NewRegistry() + reg.MustRegister(lastStatus, receivedTotal, receiverLastStatus) + reg.MustRegister(history.NewHistoryMetrics(store)) + + return &Registry{ + registry: reg, + lastStatus: lastStatus, + receivedTotal: receivedTotal, + receiverLastStatus: receiverLastStatus, + } } -// NewMetrics creates and initializes a new Metrics instance with all relevant metrics registered. -func NewMetrics(store history.Store) *Metrics { - reg := prometheus.NewRegistry() +// SetHeartbeatStatus updates the last status gauge for a heartbeat. +func (r *Registry) SetHeartbeatStatus(id string, status float64) { + r.lastStatus.WithLabelValues(id).Set(status) +} - // Register core metrics - reg.MustRegister(LastStatus) - reg.MustRegister(ReceivedTotal) - reg.MustRegister(ReceiverErrorStatus) +// IncHeartbeatReceived increments the receive counter for a heartbeat. +func (r *Registry) IncHeartbeatReceived(id string) { + r.receivedTotal.WithLabelValues(id).Inc() +} - // Register history store metrics - reg.MustRegister(history.NewHistoryMetrics(store)) +// SetReceiverStatus sets the receiver status gauge. +func (r *Registry) SetReceiverStatus(receiver, typ, target string, status float64) { + r.receiverLastStatus.WithLabelValues(receiver, typ, target).Set(status) +} - return &Metrics{ - Registry: reg, - } +// Metrics returns the Prometheus metrics handler. +func (r *Registry) Metrics() http.Handler { + return promhttp.HandlerFor(r.registry, promhttp.HandlerOpts{}) } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 3943b83..6fb9c6c 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -15,16 +15,16 @@ func TestNewMetrics(t *testing.T) { t.Parallel() hist := history.NewRingStore(10) - PromMetrics := NewMetrics(hist) - assert.NotNil(t, PromMetrics.Registry, "Registry should not be nil") + promMetrics := New(hist) + assert.NotNil(t, promMetrics.registry, "Registry should not be nil") // Initialize metrics - LastStatus.With(prometheus.Labels{"heartbeat": "test_heartbeat"}) - ReceivedTotal.With(prometheus.Labels{"heartbeat": "test_heartbeat"}) - ReceiverErrorStatus.With(prometheus.Labels{"receiver": "recv1", "type": "test-type", "target": "test-target"}) + promMetrics.SetHeartbeatStatus("test_heartbeat", UP) + promMetrics.IncHeartbeatReceived("test_heartbeat") + promMetrics.SetReceiverStatus("recv1", "test-type", "test-target", ERROR) gatherers := prometheus.Gatherers{ - PromMetrics.Registry, + promMetrics.registry, } mfs, err := gatherers.Gather() @@ -57,16 +57,16 @@ func TestLastHeartbeatStatusMetric(t *testing.T) { t.Parallel() hist := history.NewRingStore(10) - promMetrics := NewMetrics(hist) + promMetrics := New(hist) - LastStatus.With(prometheus.Labels{"heartbeat": "test_heartbeat"}).Set(UP) + promMetrics.SetHeartbeatStatus("test_heartbeat", UP) expected := ` # HELP heartbeats_heartbeat_last_status Most recent status of each heartbeat (0 = DOWN, 1 = UP) # TYPE heartbeats_heartbeat_last_status gauge heartbeats_heartbeat_last_status{heartbeat="test_heartbeat"} 1 ` - err := testutil.GatherAndCompare(promMetrics.Registry, strings.NewReader(expected), "heartbeats_heartbeat_last_status") + err := testutil.GatherAndCompare(promMetrics.registry, strings.NewReader(expected), "heartbeats_heartbeat_last_status") assert.NoError(t, err, "Expected no error while gathering and comparing metrics") } @@ -74,16 +74,16 @@ func TestReceiverErrorStatusMetrics(t *testing.T) { t.Parallel() hist := history.NewRingStore(10) - promMetrics := NewMetrics(hist) + promMetrics := New(hist) - ReceiverErrorStatus.With(prometheus.Labels{"receiver": "recv1", "target": "test-target", "type": "test-type"}).Set(1) + promMetrics.SetReceiverStatus("recv1", "test-type", "test-target", 1) expected := ` # HELP heartbeats_receiver_last_status Reports the status of the last notification attempt (1 = ERROR, 0 = SUCCESS) # TYPE heartbeats_receiver_last_status gauge heartbeats_receiver_last_status{receiver="recv1",target="test-target",type="test-type"} 1 ` - err := testutil.GatherAndCompare(promMetrics.Registry, strings.NewReader(expected), "heartbeats_receiver_last_status") + err := testutil.GatherAndCompare(promMetrics.registry, strings.NewReader(expected), "heartbeats_receiver_last_status") assert.NoError(t, err, "Expected no error while gathering and comparing metrics") } @@ -91,9 +91,9 @@ func TestReceivedTotalMetric(t *testing.T) { t.Parallel() hist := history.NewRingStore(10) - promMetrics := NewMetrics(hist) + promMetrics := New(hist) - ReceivedTotal.With(prometheus.Labels{"heartbeat": "test_heartbeat"}).Inc() + promMetrics.IncHeartbeatReceived("test_heartbeat") expected := ` # HELP heartbeats_heartbeat_received_total Total number of received heartbeats per ID @@ -101,7 +101,7 @@ func TestReceivedTotalMetric(t *testing.T) { heartbeats_heartbeat_received_total{heartbeat="test_heartbeat"} 1 ` - err := testutil.GatherAndCompare(promMetrics.Registry, strings.NewReader(expected), "heartbeats_heartbeat_received_total") + err := testutil.GatherAndCompare(promMetrics.registry, strings.NewReader(expected), "heartbeats_heartbeat_received_total") assert.NoError(t, err, "Expected no error while gathering and comparing metrics") } @@ -110,7 +110,7 @@ func TestHistorySizeMetric(t *testing.T) { size := 10_000 hist := history.NewRingStore(size) - promMetrics := NewMetrics(hist) + promMetrics := New(hist) ctx := t.Context() payload := history.RequestMetadataPayload{ @@ -133,8 +133,8 @@ func TestHistorySizeMetric(t *testing.T) { heartbeats_history_byte_size %f `, got) - err := testutil.GatherAndCompare(promMetrics.Registry, strings.NewReader(expected), "heartbeats_history_byte_size") + err := testutil.GatherAndCompare(promMetrics.registry, strings.NewReader(expected), "heartbeats_history_byte_size") assert.NoError(t, err, "Expected no error while gathering and comparing metrics") - assert.Equal(t, float64(1.83e+06), got) + assert.Equal(t, float64(1.91e+06), got) } diff --git a/internal/notifier/dispatcher.go b/internal/notifier/dispatcher.go index e746518..efe382c 100644 --- a/internal/notifier/dispatcher.go +++ b/internal/notifier/dispatcher.go @@ -6,25 +6,26 @@ import ( "log/slog" "time" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/metrics" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // Dispatcher handles queued notifications via mailbox. type Dispatcher struct { - store *ReceiverStore // maps receiver IDs → []Notifier - history history.Store // stores notifications - logger *slog.Logger // logs any notify errors - retries int // number of retries for notifications - delay time.Duration // delay between retries - mailbox chan NotificationData // channel for receiving notifications + store *ReceiverStore // maps receiver IDs → []Notifier + history *servicehistory.Recorder // stores notifications + logger *slog.Logger // logs any notify errors + retries int // number of retries for notifications + delay time.Duration // delay between retries + mailbox chan NotificationData // channel for receiving notifications + metrics *metrics.Registry // metrics registry } // NewDispatcher returns a Dispatcher backed by the given store. func NewDispatcher( store *ReceiverStore, logger *slog.Logger, - hist history.Store, + hist *servicehistory.Recorder, retries int, delay time.Duration, bufferSize int, @@ -86,18 +87,19 @@ func (d *Dispatcher) Get(id string) []Notifier { // sendWithRetry retries a notification and records its outcome. func (d *Dispatcher) sendWithRetry(ctx context.Context, receiverID string, n Notifier, data NotificationData) { - payload := history.NotificationPayload{ + factory := servicehistory.NewFactory() + payload := servicehistory.NotificationPayload{ Receiver: receiverID, Type: n.Type(), Target: n.Target(), } - eventType := history.EventTypeNotificationSent + eventType := servicehistory.EventTypeNotificationSent receiverStatus := metrics.SUCCESS if err := d.retryNotify(ctx, n, data, receiverID); err != nil { payload.Error = err.Error() - eventType = history.EventTypeNotificationFailed + eventType = servicehistory.EventTypeNotificationFailed receiverStatus = metrics.ERROR @@ -109,11 +111,14 @@ func (d *Dispatcher) sendWithRetry(ctx context.Context, receiverID string, n Not ) } - metrics.ReceiverErrorStatus. - WithLabelValues(receiverID, n.Type(), n.Target()). - Set(receiverStatus) + d.metrics.SetReceiverStatus(receiverID, n.Type(), n.Target(), receiverStatus) - ev := history.MustNewEvent(eventType, data.ID, payload) + var ev servicehistory.Event + if eventType == servicehistory.EventTypeNotificationFailed { + ev = factory.NotificationFailed(data.ID, payload.Receiver, payload.Type, payload.Target, payload.Error) + } else { + ev = factory.NotificationSent(data.ID, payload.Receiver, payload.Type, payload.Target) + } if err := d.history.Append(ctx, ev); err != nil { d.logger.Error("failed to record state change", "err", err) } diff --git a/internal/notifier/dispatcher_test.go b/internal/notifier/dispatcher_test.go index 84bfa7f..d8056c3 100644 --- a/internal/notifier/dispatcher_test.go +++ b/internal/notifier/dispatcher_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/containeroo/heartbeats/internal/history" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -26,7 +27,7 @@ func TestDispatcher_Dispatch(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) // Buffer size = 1 for test - dispatcher := NewDispatcher(store, logger, hist, 1, 1*time.Millisecond, 1) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) // Start dispatcher loop ctx := t.Context() @@ -58,7 +59,7 @@ func TestDispatcher_Dispatch(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := NewReceiverStore() hist := history.NewRingStore(10) - dispatcher := NewDispatcher(store, logger, hist, 1, 1*time.Millisecond, 1) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) // Start dispatcher loop ctx := t.Context() @@ -92,7 +93,7 @@ func TestDispatcher_Dispatch(t *testing.T) { }, } - dispatcher := NewDispatcher(store, logger, &mockHist, 1, 1*time.Millisecond, 1) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(&mockHist), 1, 1*time.Millisecond, 1, nil) // Start dispatcher loop ctx := t.Context() @@ -129,7 +130,7 @@ func TestDispatcher_ListAndGet(t *testing.T) { hist := history.NewRingStore(10) // Added buffer size (doesn't matter for this test since we don't use the mailbox) - dispatcher := NewDispatcher(store, logger, hist, 1, 1*time.Millisecond, 1) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) t.Run("lists all receivers", func(t *testing.T) { t.Parallel() @@ -166,10 +167,11 @@ func TestDispatcher_LogsErrorFromNotifier(t *testing.T) { dispatcher := NewDispatcher( store, logger, - &history.MockStore{}, + servicehistory.NewRecorder(&history.MockStore{}), 1, 1*time.Millisecond, 1, // buffer size + nil, ) // Start dispatcher loop diff --git a/internal/server/routes.go b/internal/server/routes.go index a8001b3..a8005ec 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,10 +6,8 @@ import ( "net/http" "github.com/containeroo/heartbeats/internal/handlers" - "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/middleware" - "github.com/containeroo/heartbeats/internal/notifier" + "github.com/containeroo/heartbeats/internal/service/health" ) // NewRouter creates a new HTTP router @@ -18,9 +16,7 @@ func NewRouter( siteRoot string, routePrefix string, version string, - mgr *heartbeat.Manager, - hist history.Store, - disp *notifier.Dispatcher, + api *handlers.API, logger *slog.Logger, debug bool, ) http.Handler { @@ -31,16 +27,16 @@ func NewRouter( fileServer := http.FileServer(http.FS(staticContent)) root.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) - root.Handle("/", handlers.HomeHandler(webFS, version)) // no Method allowed, otherwise it crashes - root.Handle("GET /partials/", http.StripPrefix("/partials", handlers.PartialHandler(webFS, siteRoot, mgr, hist, disp, logger))) - root.Handle("GET /healthz", handlers.Healthz()) - root.Handle("GET /metrics", handlers.Metrics(hist)) + root.Handle("/", api.HomeHandler()) // no Method allowed, otherwise it crashes + root.Handle("GET /partials/", http.StripPrefix("/partials", api.PartialHandler(siteRoot))) + root.Handle("GET /healthz", api.Healthz(health.NewService())) + root.Handle("GET /metrics", api.Metrics()) // define your API routes on a sub-mux - root.Handle("POST /bump/{id}", handlers.BumpHandler(mgr, hist, logger)) - root.Handle("GET /bump/{id}", handlers.BumpHandler(mgr, hist, logger)) - root.Handle("POST /bump/{id}/fail", handlers.FailHandler(mgr, hist, logger)) - root.Handle("GET /bump/{id}/fail", handlers.FailHandler(mgr, hist, logger)) + root.Handle("POST /bump/{id}", api.BumpHandler()) + root.Handle("GET /bump/{id}", api.BumpHandler()) + root.Handle("POST /bump/{id}/fail", api.FailHandler()) + root.Handle("GET /bump/{id}/fail", api.FailHandler()) // Mount the whole app under the prefix if provided var handler http.Handler = root diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 3637d0a..ef048a0 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "log/slog" "net/http" "net/http/httptest" @@ -10,9 +11,12 @@ import ( "testing/fstest" "time" + "github.com/containeroo/heartbeats/internal/domain" + "github.com/containeroo/heartbeats/internal/handlers" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -44,12 +48,14 @@ func TestNewRouter(t *testing.T) { } logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, nil, 0, 0, 10) hist := history.NewRingStore(10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) - router := NewRouter(webFS, siteRoot, "", version, mgr, hist, disp, logger, true) + api := handlers.NewAPI(version, "test", webFS, logger, mgr, hist, recorder, disp, nil) + router := NewRouter(webFS, siteRoot, "", version, api, logger, true) t.Run("GET /", func(t *testing.T) { t.Parallel() @@ -81,7 +87,9 @@ func TestNewRouter(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) }) t.Run("GET /bump/test", func(t *testing.T) { @@ -92,6 +100,8 @@ func TestNewRouter(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "ok", rec.Body.String()) + var resp domain.StatusResponse + assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) }) } diff --git a/internal/service/bump/bump.go b/internal/service/bump/bump.go index 61240af..2110e14 100644 --- a/internal/service/bump/bump.go +++ b/internal/service/bump/bump.go @@ -7,7 +7,7 @@ import ( "log/slog" "github.com/containeroo/heartbeats/internal/heartbeat" - "github.com/containeroo/heartbeats/internal/history" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // ErrUnknownHeartbeat signals that the heartbeat ID is not registered. @@ -17,7 +17,7 @@ var ErrUnknownHeartbeat = errors.New("unknown heartbeat id") func Receive( ctx context.Context, mgr *heartbeat.Manager, - hist history.Store, + hist *servicehistory.Recorder, logger *slog.Logger, id string, source string, @@ -31,12 +31,8 @@ func Receive( logger.Info("received bump", "id", id, "from", source) - payload := history.RequestMetadataPayload{ - Source: source, - Method: method, - UserAgent: userAgent, - } - ev := history.MustNewEvent(history.EventTypeHeartbeatReceived, id, payload) + factory := servicehistory.NewFactory() + ev := factory.HeartbeatReceived(id, source, method, userAgent) if err := hist.Append(ctx, ev); err != nil { logger.Error("failed to record state change", "err", err) @@ -53,7 +49,7 @@ func Receive( func Fail( ctx context.Context, mgr *heartbeat.Manager, - hist history.Store, + hist *servicehistory.Recorder, logger *slog.Logger, id string, source string, @@ -67,12 +63,8 @@ func Fail( logger.Info("manual fail", "id", id, "from", source) - payload := history.RequestMetadataPayload{ - Source: source, - Method: method, - UserAgent: userAgent, - } - ev := history.MustNewEvent(history.EventTypeHeartbeatFailed, id, payload) + factory := servicehistory.NewFactory() + ev := factory.HeartbeatFailed(id, source, method, userAgent) if err := hist.Append(ctx, ev); err != nil { logger.Error("failed to record state change", "err", err) diff --git a/internal/service/bump/bump_test.go b/internal/service/bump/bump_test.go index 6c91bfe..8118e8b 100644 --- a/internal/service/bump/bump_test.go +++ b/internal/service/bump/bump_test.go @@ -2,6 +2,7 @@ package bump import ( "context" + "encoding/json" "errors" "log/slog" "strings" @@ -11,15 +12,17 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) -func setupManager(t *testing.T, hist history.Store, hbName string) *heartbeat.Manager { +func setupManager(t *testing.T, hist history.Store, hbName string) (*heartbeat.Manager, *servicehistory.Recorder) { t.Helper() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.NewReceiverStore() - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) cfg := heartbeat.HeartbeatConfigMap{ hbName: { @@ -30,7 +33,7 @@ func setupManager(t *testing.T, hist history.Store, hbName string) *heartbeat.Ma Receivers: []string{"r1"}, }, } - return heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), hist, logger) + return heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger, nil), recorder } func findEventByType(t *testing.T, events []history.Event, h history.EventType) *history.Event { @@ -49,16 +52,16 @@ func TestReceive(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) - mgr := setupManager(t, hist, "hb1") + mgr, recorder := setupManager(t, hist, "hb1") - err := Receive(context.Background(), mgr, hist, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") + err := Receive(context.Background(), mgr, recorder, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") assert.NoError(t, err) events := hist.ListByID("hb1") ev := findEventByType(t, events, history.EventTypeHeartbeatReceived) if assert.NotNil(t, ev) { var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "GET", meta.Method) assert.Equal(t, "1.2.3.4:5678", meta.Source) assert.Equal(t, "Go-test", meta.UserAgent) @@ -70,9 +73,9 @@ func TestReceiveUnknownHeartbeat(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) - mgr := setupManager(t, hist, "hb1") + mgr, recorder := setupManager(t, hist, "hb1") - err := Receive(context.Background(), mgr, hist, logger, "missing", "1.2.3.4:5678", "GET", "Go-test") + err := Receive(context.Background(), mgr, recorder, logger, "missing", "1.2.3.4:5678", "GET", "Go-test") assert.ErrorIs(t, err, ErrUnknownHeartbeat) } @@ -86,9 +89,9 @@ func TestReceiveAppendError(t *testing.T) { return expectedErr }, } - mgr := setupManager(t, mockStore, "hb1") + mgr, recorder := setupManager(t, mockStore, "hb1") - err := Receive(context.Background(), mgr, mockStore, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") + err := Receive(context.Background(), mgr, recorder, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") assert.ErrorIs(t, err, expectedErr) } @@ -97,16 +100,16 @@ func TestFail(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) - mgr := setupManager(t, hist, "hb1") + mgr, recorder := setupManager(t, hist, "hb1") - err := Fail(context.Background(), mgr, hist, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") + err := Fail(context.Background(), mgr, recorder, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") assert.NoError(t, err) events := hist.ListByID("hb1") ev := findEventByType(t, events, history.EventTypeHeartbeatFailed) if assert.NotNil(t, ev) { var meta history.RequestMetadataPayload - assert.NoError(t, ev.DecodePayload(&meta)) + assert.NoError(t, json.Unmarshal(ev.RawPayload, &meta)) assert.Equal(t, "POST", meta.Method) assert.Equal(t, "1.2.3.4:5678", meta.Source) assert.Equal(t, "Go-test", meta.UserAgent) @@ -118,9 +121,9 @@ func TestFailUnknownHeartbeat(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) - mgr := setupManager(t, hist, "hb1") + mgr, recorder := setupManager(t, hist, "hb1") - err := Fail(context.Background(), mgr, hist, logger, "missing", "1.2.3.4:5678", "POST", "Go-test") + err := Fail(context.Background(), mgr, recorder, logger, "missing", "1.2.3.4:5678", "POST", "Go-test") assert.ErrorIs(t, err, ErrUnknownHeartbeat) } @@ -134,8 +137,8 @@ func TestFailAppendError(t *testing.T) { return expectedErr }, } - mgr := setupManager(t, mockStore, "hb1") + mgr, recorder := setupManager(t, mockStore, "hb1") - err := Fail(context.Background(), mgr, mockStore, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") + err := Fail(context.Background(), mgr, recorder, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") assert.ErrorIs(t, err, expectedErr) } diff --git a/internal/service/health/status.go b/internal/service/health/status.go new file mode 100644 index 0000000..d5f6ec3 --- /dev/null +++ b/internal/service/health/status.go @@ -0,0 +1,19 @@ +package health + +// Service exposes health-related utilities. +type Service struct{} + +// NewService constructs a new health service. +func NewService() *Service { + return &Service{} +} + +// Status returns the current health status payload. +func (s *Service) Status() Status { + return Status{Status: "ok"} +} + +// Status is the domain status for health checks. +type Status struct { + Status string +} diff --git a/internal/service/history/event.go b/internal/service/history/event.go new file mode 100644 index 0000000..e69ea7c --- /dev/null +++ b/internal/service/history/event.go @@ -0,0 +1,214 @@ +package history + +import ( + "context" + "errors" + "fmt" + + storehistory "github.com/containeroo/heartbeats/internal/history" +) + +// EventType categorizes what happened. +type EventType string + +const ( + EventTypeHeartbeatReceived EventType = "HeartbeatReceived" + EventTypeHeartbeatFailed EventType = "HeartbeatFailed" + EventTypeStateChanged EventType = "StateChanged" + EventTypeNotificationSent EventType = "NotificationSent" + EventTypeNotificationFailed EventType = "NotificationFailed" +) + +// Factory builds typed events from business actions. +type Factory struct{} + +// NewFactory constructs a history event factory. +func NewFactory() *Factory { + return &Factory{} +} + +// HeartbeatReceived builds a heartbeat-received event. +func (f *Factory) HeartbeatReceived(id, source, method, userAgent string) Event { + return Event{ + Type: EventTypeHeartbeatReceived, + HeartbeatID: id, + Payload: RequestMetadataPayload{ + Source: source, + Method: method, + UserAgent: userAgent, + }, + } +} + +// HeartbeatFailed builds a heartbeat-failed event. +func (f *Factory) HeartbeatFailed(id, source, method, userAgent string) Event { + return Event{ + Type: EventTypeHeartbeatFailed, + HeartbeatID: id, + Payload: RequestMetadataPayload{ + Source: source, + Method: method, + UserAgent: userAgent, + }, + } +} + +// StateChanged builds a state-changed event. +func (f *Factory) StateChanged(id, from, to string) Event { + return Event{ + Type: EventTypeStateChanged, + HeartbeatID: id, + Payload: StateChangePayload{ + From: from, + To: to, + }, + } +} + +// NotificationSent builds a notification-sent event. +func (f *Factory) NotificationSent(id, receiver, typ, target string) Event { + return Event{ + Type: EventTypeNotificationSent, + HeartbeatID: id, + Payload: NotificationPayload{ + Receiver: receiver, + Type: typ, + Target: target, + }, + } +} + +// NotificationFailed builds a notification-failed event. +func (f *Factory) NotificationFailed(id, receiver, typ, target, errMsg string) Event { + return Event{ + Type: EventTypeNotificationFailed, + HeartbeatID: id, + Payload: NotificationPayload{ + Receiver: receiver, + Type: typ, + Target: target, + Error: errMsg, + }, + } +} + +// Payload is a typed payload marker. +type Payload interface { + isPayload() +} + +// RequestMetadataPayload captures request metadata for a heartbeat bump. +type RequestMetadataPayload struct { + Source string + Method string + UserAgent string +} + +func (RequestMetadataPayload) isPayload() {} + +// StateChangePayload captures heartbeat state transitions. +type StateChangePayload struct { + From string + To string +} + +func (StateChangePayload) isPayload() {} + +// NotificationPayload captures notification delivery details. +type NotificationPayload struct { + Receiver string + Type string + Target string + Error string +} + +func (NotificationPayload) isPayload() {} + +// Event is a typed history event. +type Event struct { + Type EventType + HeartbeatID string + Payload Payload +} + +// Recorder appends typed events to the history store. +type Recorder struct { + store storehistory.Store +} + +// NewRecorder wraps a history store. +func NewRecorder(store storehistory.Store) *Recorder { + return &Recorder{store: store} +} + +// Append records a typed event. +func (r *Recorder) Append(ctx context.Context, e Event) error { + if r == nil || r.store == nil { + return errors.New("history store is nil") + } + + storeEvent, err := toStoreEvent(e) + if err != nil { + return err + } + + return r.store.Append(ctx, storeEvent) +} + +func toStoreEvent(e Event) (storehistory.Event, error) { + switch e.Type { + case EventTypeHeartbeatReceived: + p, ok := e.Payload.(RequestMetadataPayload) + if !ok { + return storehistory.Event{}, fmt.Errorf("invalid payload for %s", e.Type) + } + return storehistory.NewEvent(storehistory.EventTypeHeartbeatReceived, e.HeartbeatID, storehistory.RequestMetadataPayload{ + Source: p.Source, + Method: p.Method, + UserAgent: p.UserAgent, + }) + case EventTypeHeartbeatFailed: + p, ok := e.Payload.(RequestMetadataPayload) + if !ok { + return storehistory.Event{}, fmt.Errorf("invalid payload for %s", e.Type) + } + return storehistory.NewEvent(storehistory.EventTypeHeartbeatFailed, e.HeartbeatID, storehistory.RequestMetadataPayload{ + Source: p.Source, + Method: p.Method, + UserAgent: p.UserAgent, + }) + case EventTypeStateChanged: + p, ok := e.Payload.(StateChangePayload) + if !ok { + return storehistory.Event{}, fmt.Errorf("invalid payload for %s", e.Type) + } + return storehistory.NewEvent(storehistory.EventTypeStateChanged, e.HeartbeatID, storehistory.StateChangePayload{ + From: p.From, + To: p.To, + }) + case EventTypeNotificationSent: + p, ok := e.Payload.(NotificationPayload) + if !ok { + return storehistory.Event{}, fmt.Errorf("invalid payload for %s", e.Type) + } + return storehistory.NewEvent(storehistory.EventTypeNotificationSent, e.HeartbeatID, storehistory.NotificationPayload{ + Receiver: p.Receiver, + Type: p.Type, + Target: p.Target, + Error: p.Error, + }) + case EventTypeNotificationFailed: + p, ok := e.Payload.(NotificationPayload) + if !ok { + return storehistory.Event{}, fmt.Errorf("invalid payload for %s", e.Type) + } + return storehistory.NewEvent(storehistory.EventTypeNotificationFailed, e.HeartbeatID, storehistory.NotificationPayload{ + Receiver: p.Receiver, + Type: p.Type, + Target: p.Target, + Error: p.Error, + }) + default: + return storehistory.Event{}, fmt.Errorf("unknown event type %q", e.Type) + } +} diff --git a/internal/service/test/test_test.go b/internal/service/test/test_test.go index c16337f..5bba76b 100644 --- a/internal/service/test/test_test.go +++ b/internal/service/test/test_test.go @@ -10,6 +10,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -29,7 +30,8 @@ func TestSendTestNotification(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -55,7 +57,8 @@ func TestTriggerTestHeartbeat(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) store := notifier.NewReceiverStore() - disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) cfg := heartbeat.HeartbeatConfigMap{ "hb1": { @@ -66,7 +69,7 @@ func TestTriggerTestHeartbeat(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), hist, logger) + mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger, nil) assert.NoError(t, TriggerTestHeartbeat(mgr, logger, "hb1")) assert.EqualError(t, TriggerTestHeartbeat(mgr, logger, "missing"), "heartbeat ID \"missing\" not found") diff --git a/internal/view/partials.go b/internal/view/partials.go index 8ca0336..6a390e5 100644 --- a/internal/view/partials.go +++ b/internal/view/partials.go @@ -1,6 +1,7 @@ package view import ( + "encoding/json" "fmt" "io" "sort" @@ -118,7 +119,7 @@ func RenderHistory( tmpl *template.Template, hist history.Store, ) error { - raw := hist.List() + raw := history.ListRecent(hist, 0) views := make([]HistoryView, 0, len(raw)) for _, e := range raw { @@ -127,7 +128,7 @@ func RenderHistory( switch e.Type { case history.EventTypeNotificationSent, history.EventTypeNotificationFailed: var p history.NotificationPayload - if err := e.DecodePayload(&p); err == nil { + if len(e.RawPayload) > 0 && json.Unmarshal(e.RawPayload, &p) == nil { if p.Error != "" { det = fmt.Sprintf("Notification to %q via %s (%s) failed: %s", p.Receiver, p.Type, p.Target, p.Error, @@ -143,7 +144,7 @@ func RenderHistory( case history.EventTypeStateChanged: var p history.StateChangePayload - if err := e.DecodePayload(&p); err == nil { + if len(e.RawPayload) > 0 && json.Unmarshal(e.RawPayload, &p) == nil { det = fmt.Sprintf("%s → %s", p.From, p.To) } else { det = "Invalid state change payload" @@ -151,7 +152,7 @@ func RenderHistory( case history.EventTypeHeartbeatReceived, history.EventTypeHeartbeatFailed: var p history.RequestMetadataPayload - if err := e.DecodePayload(&p); err == nil { + if len(e.RawPayload) > 0 && json.Unmarshal(e.RawPayload, &p) == nil { det = fmt.Sprintf("%s from %s with %q", p.Method, p.Source, p.UserAgent) } else { det = "Invalid request metadata" @@ -169,10 +170,5 @@ func RenderHistory( }) } - // Newest first - sort.Slice(views, func(i, j int) bool { - return views[j].Timestamp.Before(views[i].Timestamp) - }) - return tmpl.ExecuteTemplate(w, "history", struct{ Events []HistoryView }{Events: views}) } diff --git a/internal/view/partials_test.go b/internal/view/partials_test.go index 2673fd0..5766e7f 100644 --- a/internal/view/partials_test.go +++ b/internal/view/partials_test.go @@ -14,6 +14,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -49,7 +50,8 @@ func TestRenderHeartbeats(t *testing.T) { hist := history.NewRingStore(10) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, nil, hist, 1, 1, 10) + recorder := servicehistory.NewRecorder(hist) + disp := notifier.NewDispatcher(store, nil, recorder, 1, 1, 10, nil) mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "b": { Description: "b-desc", @@ -63,7 +65,7 @@ func TestRenderHeartbeats(t *testing.T) { Grace: 1 * time.Second, Receivers: []string{"r1"}, }, - }, disp.Mailbox(), hist, nil) + }, disp.Mailbox(), recorder, nil, nil) var buf bytes.Buffer a := mgr.Get("b") @@ -88,7 +90,7 @@ func TestRenderReceivers(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(rc, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, nil, nil, 1, 1, 10) + disp := notifier.NewDispatcher(store, nil, servicehistory.NewRecorder(nil), 1, 1, 10, nil) var buf bytes.Buffer err := RenderReceivers(&buf, tmpl, disp) From 8c7254097a2f5e8880ef175b3504d008fb2ee0ef Mon Sep 17 00:00:00 2001 From: gi8 Date: Mon, 19 Jan 2026 10:32:58 +0100 Subject: [PATCH 2/4] add heartbeat factory --- internal/app/run.go | 25 ++- internal/debugserver/debugserver_test.go | 15 +- internal/handlers/api.go | 3 +- internal/handlers/bump_test.go | 35 ++-- internal/handlers/partials_test.go | 13 +- internal/handlers/test_test.go | 23 ++- internal/heartbeat/actor.go | 4 +- internal/heartbeat/actor_test.go | 2 - internal/heartbeat/factory.go | 42 +++++ internal/heartbeat/manager.go | 231 ++++++++++++++++++++--- internal/heartbeat/manager_test.go | 46 ++++- internal/notifier/dispatcher.go | 2 + internal/server/routes_test.go | 30 ++- internal/service/bump/bump_test.go | 16 +- internal/service/test/test_test.go | 15 +- internal/view/partials_test.go | 15 +- 16 files changed, 440 insertions(+), 77 deletions(-) create mode 100644 internal/heartbeat/factory.go diff --git a/internal/app/run.go b/internal/app/run.go index b88feb9..ea1e3b5 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -16,6 +16,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/logging" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" "github.com/containeroo/heartbeats/internal/server" servicehistory "github.com/containeroo/heartbeats/internal/service/history" @@ -63,6 +64,7 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string return fmt.Errorf("failed to initialize history: %w", err) } histRecorder := servicehistory.NewRecorder(histStore) + metricsReg := metrics.New(histStore) // Inizalize notification store := notifier.InitializeStore(cfg.Receivers, flags.SkipTLS, version, logger) @@ -74,17 +76,31 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string flags.RetryCount, flags.RetryDelay, bufferSize, + metricsReg, ) go dispatcher.Run(ctx) // Create Heartbeat Manager - mgr := heartbeat.NewManagerFromHeartbeatMap( + actorFactory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: histRecorder, + Metrics: metricsReg, + DispatchCh: dispatcher.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( ctx, cfg.Heartbeats, - dispatcher.Mailbox(), - histRecorder, - logger, + heartbeat.ManagerConfig{ + Logger: logger, + Factory: actorFactory, + }, ) + if err != nil { + return fmt.Errorf("failed to initialize heartbeats: %w", err) + } + mgr.StartAll() api := handlers.NewAPI( version, @@ -95,6 +111,7 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string histStore, histRecorder, dispatcher, + metricsReg, ) // Run debug server if enabled diff --git a/internal/debugserver/debugserver_test.go b/internal/debugserver/debugserver_test.go index 477cab2..bf7d58f 100644 --- a/internal/debugserver/debugserver_test.go +++ b/internal/debugserver/debugserver_test.go @@ -36,7 +36,20 @@ func TestDebugServer_Run(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, hbCfg, disp.Mailbox(), recorder, logger, nil) + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + ctx, + hbCfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + require.NoError(t, err) api := handlers.NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) // Pick a random free port diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 585415d..4e7db1b 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -34,6 +34,7 @@ func NewAPI( hist history.Store, rec *servicehistory.Recorder, disp *notifier.Dispatcher, + metricsReg *metrics.Registry, ) *API { return &API{ Version: version, @@ -44,7 +45,7 @@ func NewAPI( hist: hist, rec: rec, disp: disp, - metrics: metrics.New(hist), + metrics: metricsReg, } } diff --git a/internal/handlers/bump_test.go b/internal/handlers/bump_test.go index fbc14c0..e8bb067 100644 --- a/internal/handlers/bump_test.go +++ b/internal/handlers/bump_test.go @@ -36,7 +36,20 @@ func setupRouter(t *testing.T, hbName string, hist history.Store) http.Handler { store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), heartbeats, disp.Mailbox(), recorder, logger, nil) + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + context.Background(), + heartbeats, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) api := NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) router := http.NewServeMux() router.Handle("GET /no-id/", api.BumpHandler()) @@ -63,7 +76,7 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "missing id", resp.Error) }) @@ -78,7 +91,7 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "unknown heartbeat id \"not-found\"", resp.Error) }) @@ -97,7 +110,7 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp statusResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) @@ -127,7 +140,7 @@ func TestBumpHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp statusResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) @@ -158,7 +171,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "missing id", resp.Error) }) @@ -173,7 +186,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "unknown heartbeat id \"not-found\"", resp.Error) }) @@ -192,7 +205,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp statusResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) @@ -224,7 +237,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp statusResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) @@ -258,7 +271,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "fail!", resp.Error) }) @@ -282,7 +295,7 @@ func TestFailHandler(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) - var resp domain.ErrorResponse + var resp errorResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "fail!", resp.Error) }) diff --git a/internal/handlers/partials_test.go b/internal/handlers/partials_test.go index 8c420d9..272167d 100644 --- a/internal/handlers/partials_test.go +++ b/internal/handlers/partials_test.go @@ -42,14 +42,23 @@ func TestPartialHandler(t *testing.T) { recorder := servicehistory.NewRecorder(hist) disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "hb1": { Description: "desc", Interval: 10 * time.Second, Grace: 5 * time.Second, Receivers: []string{"r1"}, }, - }, disp.Mailbox(), recorder, logger, nil) + }, heartbeat.ManagerConfig{Logger: logger, Factory: factory}) + assert.NoError(t, err) api := NewAPI("test", "test", webFS, logger, mgr, hist, recorder, disp, nil) t.Run("not found", func(t *testing.T) { diff --git a/internal/handlers/test_test.go b/internal/handlers/test_test.go index f3cbfd7..dd72a3a 100644 --- a/internal/handlers/test_test.go +++ b/internal/handlers/test_test.go @@ -25,8 +25,8 @@ func TestTestReceiverHandler(t *testing.T) { hist := history.NewRingStore(10) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10) - api := NewAPI("test", "test", nil, logger, nil, hist, recorder, disp) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + api := NewAPI("test", "test", nil, logger, nil, hist, recorder, disp, nil) handler := api.TestReceiverHandler() @@ -69,7 +69,7 @@ func TestTestHeartbeatHandler(t *testing.T) { hist := history.NewRingStore(10) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) hbName := "test-hb" cfg := heartbeat.HeartbeatConfigMap{ @@ -81,8 +81,21 @@ func TestTestHeartbeatHandler(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger) - api := NewAPI(logger, "test", "test", nil, mgr, hist, recorder, disp) + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + context.Background(), + cfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) + api := NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) handler := api.TestHeartbeatHandler() t.Run("missing id", func(t *testing.T) { diff --git a/internal/heartbeat/actor.go b/internal/heartbeat/actor.go index 809ba27..4d2e645 100644 --- a/internal/heartbeat/actor.go +++ b/internal/heartbeat/actor.go @@ -37,7 +37,6 @@ type Actor struct { // ActorConfig holds all parameters required to construct a heartbeat Actor. type ActorConfig struct { - Ctx context.Context // context for cancellation and lifecycle control ID string // unique heartbeat identifier Description string // human-readable description of the heartbeat Interval time.Duration // expected interval between heartbeat pings @@ -52,7 +51,7 @@ type ActorConfig struct { // NewActorFromConfig creates a new heartbeat actor. func NewActorFromConfig(cfg ActorConfig) *Actor { return &Actor{ - ctx: cfg.Ctx, + ctx: context.Background(), ID: cfg.ID, Description: cfg.Description, Interval: cfg.Interval, @@ -72,6 +71,7 @@ func (a *Actor) Mailbox() chan<- common.EventType { return a.mailbox } // Run starts the actor loop and handles incoming events and timers. func (a *Actor) Run(ctx context.Context) { + a.ctx = ctx for { // prepare active channels var checkCh, graceCh, delayCh <-chan time.Time diff --git a/internal/heartbeat/actor_test.go b/internal/heartbeat/actor_test.go index 3058458..18039d3 100644 --- a/internal/heartbeat/actor_test.go +++ b/internal/heartbeat/actor_test.go @@ -73,7 +73,6 @@ func TestActor_Run_Smoke(t *testing.T) { go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ - Ctx: ctx, ID: "heartbeat-1", Description: "Test Actor", Interval: 100 * time.Millisecond, @@ -125,7 +124,6 @@ func TestActor_Run_Smoke(t *testing.T) { go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ - Ctx: ctx, ID: "heartbeat-2", Description: "Test Actor", Interval: 100 * time.Millisecond, diff --git a/internal/heartbeat/factory.go b/internal/heartbeat/factory.go new file mode 100644 index 0000000..eaf44ed --- /dev/null +++ b/internal/heartbeat/factory.go @@ -0,0 +1,42 @@ +package heartbeat + +import ( + "log/slog" + + "github.com/containeroo/heartbeats/internal/metrics" + "github.com/containeroo/heartbeats/internal/notifier" + servicehistory "github.com/containeroo/heartbeats/internal/service/history" +) + +// ActorFactory builds actors from heartbeat configs. +type ActorFactory interface { + Build(cfg HeartbeatConfig) (*Actor, error) +} + +// ActorDeps bundles shared dependencies for actors. +type ActorDeps struct { + Logger *slog.Logger + History *servicehistory.Recorder + Metrics *metrics.Registry + DispatchCh chan<- notifier.NotificationData +} + +// DefaultActorFactory builds actors using the shared dependencies. +type DefaultActorFactory struct { + Deps ActorDeps +} + +// Build constructs a new Actor without starting it. +func (f DefaultActorFactory) Build(cfg HeartbeatConfig) (*Actor, error) { + return NewActorFromConfig(ActorConfig{ + ID: cfg.ID, + Description: cfg.Description, + Interval: cfg.Interval, + Grace: cfg.Grace, + Receivers: cfg.Receivers, + Logger: f.Deps.Logger, + History: f.Deps.History, + DispatchCh: f.Deps.DispatchCh, + Metrics: f.Deps.Metrics, + }), nil +} diff --git a/internal/heartbeat/manager.go b/internal/heartbeat/manager.go index 16c5617..f1c2c3c 100644 --- a/internal/heartbeat/manager.go +++ b/internal/heartbeat/manager.go @@ -4,50 +4,193 @@ import ( "context" "fmt" "log/slog" + "slices" "github.com/containeroo/heartbeats/internal/common" - "github.com/containeroo/heartbeats/internal/notifier" - servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) // Manager routes HTTP pings to Actors. type Manager struct { - actors map[string]*Actor - logger *slog.Logger + actors map[string]*managedActor + logger *slog.Logger + baseCtx context.Context + factory ActorFactory + validate func(HeartbeatConfig) error + postCreate func(HeartbeatConfig, *Actor) error + started bool } -// NewManagerFromHeartbeatMap creates a Manager from heartbeat config and launches all actors. +type managedActor struct { + actor *Actor + cfg HeartbeatConfig + started bool + cancel context.CancelFunc +} + +// ManagerConfig configures manager construction and actor validation hooks. +type ManagerConfig struct { + Logger *slog.Logger + Factory ActorFactory + Validate func(HeartbeatConfig) error + PostCreate func(HeartbeatConfig, *Actor) error +} + +// NewManagerFromHeartbeatMap creates a Manager from heartbeat config without starting actors. func NewManagerFromHeartbeatMap( ctx context.Context, heartbeatConfigs HeartbeatConfigMap, - dispatchCh chan<- notifier.NotificationData, - hist *servicehistory.Recorder, - logger *slog.Logger, -) *Manager { - m := &Manager{actors: make(map[string]*Actor, len(heartbeatConfigs)), logger: logger} + cfg ManagerConfig, +) (*Manager, error) { + if cfg.Factory == nil { + return nil, fmt.Errorf("manager requires an actor factory") + } + m := &Manager{ + actors: make(map[string]*managedActor, len(heartbeatConfigs)), + logger: cfg.Logger, + baseCtx: ctx, + factory: cfg.Factory, + validate: cfg.Validate, + postCreate: cfg.PostCreate, + } for id, hb := range heartbeatConfigs { - actor := NewActorFromConfig(ActorConfig{ - Ctx: ctx, - ID: id, - Description: hb.Description, - Interval: hb.Interval, - Grace: hb.Grace, - Receivers: hb.Receivers, - Logger: logger, - History: hist, - DispatchCh: dispatchCh, - }) - m.actors[id] = actor - go actor.Run(ctx) - } - return m + if hb.ID == "" { + hb.ID = id + } + if cfg.Validate != nil { + if err := cfg.Validate(hb); err != nil { + return nil, fmt.Errorf("invalid heartbeat %q: %w", id, err) + } + } + actor, err := cfg.Factory.Build(hb) + if err != nil { + return nil, fmt.Errorf("build heartbeat %q: %w", id, err) + } + if cfg.PostCreate != nil { + if err := cfg.PostCreate(hb, actor); err != nil { + return nil, fmt.Errorf("validate heartbeat %q: %w", id, err) + } + } + m.actors[id] = &managedActor{actor: actor, cfg: hb} + } + return m, nil +} + +// StartAll starts all actors that are not running yet. +func (m *Manager) StartAll() int { + started := 0 + for _, ma := range m.actors { + if ma.started { + continue + } + ctx, cancel := context.WithCancel(m.baseCtx) + ma.cancel = cancel + ma.started = true + go ma.actor.Run(ctx) + started++ + } + if started > 0 { + m.started = true + } + return started } // List returns all configured heartbeats. -func (m *Manager) List() map[string]*Actor { return m.actors } +func (m *Manager) List() map[string]*Actor { + result := make(map[string]*Actor, len(m.actors)) + for id, ma := range m.actors { + result[id] = ma.actor + } + return result +} // Get returns one heartbeat’s info by ID. -func (m *Manager) Get(id string) *Actor { return m.actors[id] } +func (m *Manager) Get(id string) *Actor { + ma, ok := m.actors[id] + if !ok { + return nil + } + return ma.actor +} + +// Reconcile updates the manager with a new config set without rebuilding everything. +func (m *Manager) Reconcile(heartbeatConfigs HeartbeatConfigMap) (ReconcileResult, error) { + var res ReconcileResult + + for id, ma := range m.actors { + if _, ok := heartbeatConfigs[id]; !ok { + m.stopManaged(ma) + delete(m.actors, id) + res.Removed++ + } + } + + for id, hb := range heartbeatConfigs { + if hb.ID == "" { + hb.ID = id + } + if ma, ok := m.actors[id]; ok { + if sameConfig(ma.actor, hb) { + ma.cfg = hb + continue + } + if m.validate != nil { + if err := m.validate(hb); err != nil { + return res, fmt.Errorf("invalid heartbeat %q: %w", id, err) + } + } + actor, err := m.factory.Build(hb) + if err != nil { + return res, fmt.Errorf("build heartbeat %q: %w", id, err) + } + if err := m.postCreate(hb, actor); err != nil { + return res, fmt.Errorf("validate heartbeat %q: %w", id, err) + } + m.stopManaged(ma) + newActor := &managedActor{actor: actor, cfg: hb} + if m.started { + ctx, cancel := context.WithCancel(m.baseCtx) + newActor.cancel = cancel + newActor.started = true + go actor.Run(ctx) + } + m.actors[id] = newActor + res.Updated++ + continue + } + + if m.validate != nil { + if err := m.validate(hb); err != nil { + return res, fmt.Errorf("invalid heartbeat %q: %w", id, err) + } + } + actor, err := m.factory.Build(hb) + if err != nil { + return res, fmt.Errorf("build heartbeat %q: %w", id, err) + } + if err := m.postCreate(hb, actor); err != nil { + return res, fmt.Errorf("validate heartbeat %q: %w", id, err) + } + + newActor := &managedActor{actor: actor, cfg: hb} + if m.started { + ctx, cancel := context.WithCancel(m.baseCtx) + newActor.cancel = cancel + newActor.started = true + go actor.Run(ctx) + } + m.actors[id] = newActor + res.Added++ + } + + return res, nil +} + +// ReconcileResult reports how many actors were added, updated, or removed. +type ReconcileResult struct { + Added int + Updated int + Removed int +} // Receive notifies the Actor with a heartbeat "receive" event. func (m *Manager) Receive(id string) error { @@ -55,7 +198,7 @@ func (m *Manager) Receive(id string) error { if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.Mailbox() <- common.EventReceive + a.actor.Mailbox() <- common.EventReceive return nil } @@ -65,7 +208,7 @@ func (m *Manager) Fail(id string) error { if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.Mailbox() <- common.EventFail + a.actor.Mailbox() <- common.EventFail return nil } @@ -75,6 +218,34 @@ func (m *Manager) Test(id string) error { if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.Mailbox() <- common.EventTest + a.actor.Mailbox() <- common.EventTest return nil } + +func (m *Manager) stopManaged(ma *managedActor) { + if ma == nil || !ma.started || ma.cancel == nil { + return + } + ma.cancel() + ma.cancel = nil + ma.started = false +} + +func sameConfig(a *Actor, cfg HeartbeatConfig) bool { + if a == nil { + return false + } + if a.ID != cfg.ID { + return false + } + if a.Description != cfg.Description { + return false + } + if a.Interval != cfg.Interval { + return false + } + if a.Grace != cfg.Grace { + return false + } + return slices.Equal(a.Receivers, cfg.Receivers) +} diff --git a/internal/heartbeat/manager_test.go b/internal/heartbeat/manager_test.go index 26f9d5b..b254d14 100644 --- a/internal/heartbeat/manager_test.go +++ b/internal/heartbeat/manager_test.go @@ -15,6 +15,33 @@ import ( "github.com/stretchr/testify/assert" ) +func newTestManager( + t *testing.T, + ctx context.Context, + cfg heartbeat.HeartbeatConfigMap, + disp *notifier.Dispatcher, + recorder *servicehistory.Recorder, + logger *slog.Logger, +) *heartbeat.Manager { + t.Helper() + + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + ctx, + cfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) + return mgr +} + func TestManager_HandleReceive(t *testing.T) { t.Parallel() @@ -37,7 +64,8 @@ func TestManager_HandleReceive(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr.StartAll() err := mgr.Receive("a1") assert.NoError(t, err) @@ -51,7 +79,7 @@ func TestManager_HandleReceive(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) err := mgr.Receive("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -80,7 +108,8 @@ func TestManager_HandleFail(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr.StartAll() err := mgr.Fail("a1") assert.NoError(t, err) @@ -94,7 +123,7 @@ func TestManager_HandleFail(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) err := mgr.Fail("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -123,7 +152,8 @@ func TestManager_HandleTest(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr.StartAll() err := mgr.Test("a1") assert.NoError(t, err) @@ -136,7 +166,7 @@ func TestManager_HandleTest(t *testing.T) { t.Parallel() cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) err := mgr.Test("does-not-exist") assert.Error(t, err) @@ -169,7 +199,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) result := mgr.List() assert.Len(t, result, 2) @@ -200,7 +230,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) + mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) t.Run("Get found", func(t *testing.T) { t.Parallel() diff --git a/internal/notifier/dispatcher.go b/internal/notifier/dispatcher.go index efe382c..f0754b2 100644 --- a/internal/notifier/dispatcher.go +++ b/internal/notifier/dispatcher.go @@ -29,6 +29,7 @@ func NewDispatcher( retries int, delay time.Duration, bufferSize int, + metricsReg *metrics.Registry, ) *Dispatcher { return &Dispatcher{ store: store, @@ -37,6 +38,7 @@ func NewDispatcher( retries: retries, delay: delay, mailbox: make(chan NotificationData, bufferSize), + metrics: metricsReg, } } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index ef048a0..86635c9 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -11,10 +11,10 @@ import ( "testing/fstest" "time" - "github.com/containeroo/heartbeats/internal/domain" "github.com/containeroo/heartbeats/internal/handlers" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" @@ -49,12 +49,26 @@ func TestNewRouter(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(nil, false, "0.0.0", logger) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) - mgr := heartbeat.NewManagerFromHeartbeatMap(ctx, cfg, disp.Mailbox(), recorder, logger, nil) - - api := handlers.NewAPI(version, "test", webFS, logger, mgr, hist, recorder, disp, nil) + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + ctx, + cfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) + + api := handlers.NewAPI(version, "test", webFS, logger, mgr, hist, recorder, disp, metricsReg) router := NewRouter(webFS, siteRoot, "", version, api, logger, true) t.Run("GET /", func(t *testing.T) { @@ -87,7 +101,9 @@ func TestNewRouter(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp struct { + Status string `json:"status"` + } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) }) @@ -100,7 +116,9 @@ func TestNewRouter(t *testing.T) { router.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var resp domain.StatusResponse + var resp struct { + Status string `json:"status"` + } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) assert.Equal(t, "ok", resp.Status) }) diff --git a/internal/service/bump/bump_test.go b/internal/service/bump/bump_test.go index 8118e8b..9005ea8 100644 --- a/internal/service/bump/bump_test.go +++ b/internal/service/bump/bump_test.go @@ -33,7 +33,21 @@ func setupManager(t *testing.T, hist history.Store, hbName string) (*heartbeat.M Receivers: []string{"r1"}, }, } - return heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger, nil), recorder + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + context.Background(), + cfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) + return mgr, recorder } func findEventByType(t *testing.T, events []history.Event, h history.EventType) *history.Event { diff --git a/internal/service/test/test_test.go b/internal/service/test/test_test.go index 5bba76b..dc939ff 100644 --- a/internal/service/test/test_test.go +++ b/internal/service/test/test_test.go @@ -69,7 +69,20 @@ func TestTriggerTestHeartbeat(t *testing.T) { Receivers: []string{"r1"}, }, } - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), recorder, logger, nil) + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: logger, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap( + context.Background(), + cfg, + heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + ) + assert.NoError(t, err) assert.NoError(t, TriggerTestHeartbeat(mgr, logger, "hb1")) assert.EqualError(t, TriggerTestHeartbeat(mgr, logger, "missing"), "heartbeat ID \"missing\" not found") diff --git a/internal/view/partials_test.go b/internal/view/partials_test.go index 5766e7f..cf6af59 100644 --- a/internal/view/partials_test.go +++ b/internal/view/partials_test.go @@ -52,7 +52,15 @@ func TestRenderHeartbeats(t *testing.T) { store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) disp := notifier.NewDispatcher(store, nil, recorder, 1, 1, 10, nil) - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ + factory := heartbeat.DefaultActorFactory{ + Deps: heartbeat.ActorDeps{ + Logger: nil, + History: recorder, + Metrics: nil, + DispatchCh: disp.Mailbox(), + }, + } + mgr, err := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "b": { Description: "b-desc", Interval: 1 * time.Second, @@ -65,13 +73,14 @@ func TestRenderHeartbeats(t *testing.T) { Grace: 1 * time.Second, Receivers: []string{"r1"}, }, - }, disp.Mailbox(), recorder, nil, nil) + }, heartbeat.ManagerConfig{Factory: factory}) + assert.NoError(t, err) var buf bytes.Buffer a := mgr.Get("b") a.LastBump = time.Now() - err := RenderHeartbeats(&buf, tmpl, "http://localhost", mgr, hist) + err = RenderHeartbeats(&buf, tmpl, "http://localhost", mgr, hist) assert.NoError(t, err) assert.Contains(t, buf.String(), "a:idle;b:idle") } From 90e83142a74d39c39e379b2e7b6e5881552f7662 Mon Sep 17 00:00:00 2001 From: gi8 Date: Mon, 19 Jan 2026 14:35:27 +0100 Subject: [PATCH 3/4] improvements --- README.md | 10 ++ internal/app/run.go | 38 ++++--- internal/config/config.go | 76 ++++++++++++++ internal/config/reload_test.go | 39 +++++++ internal/debugserver/debugserver_test.go | 33 ++++-- internal/flag/flag.go | 4 +- internal/handlers/api.go | 48 +++++---- internal/handlers/bump_test.go | 33 ++++-- internal/handlers/healthz_test.go | 16 ++- internal/handlers/home_test.go | 32 +++++- internal/handlers/metrics_test.go | 17 +++- internal/handlers/partials_test.go | 35 +++++-- internal/handlers/reload.go | 18 ++++ internal/handlers/reload_test.go | 52 ++++++++++ internal/handlers/test_test.go | 52 ++++++++-- internal/heartbeat/actor.go | 41 ++++---- internal/heartbeat/actor_test.go | 101 ++++++++++++------- internal/heartbeat/factory.go | 21 ++-- internal/heartbeat/manager.go | 100 ++++++++---------- internal/heartbeat/manager_test.go | 53 +++++----- internal/{common => heartbeat}/types.go | 2 +- internal/{common => heartbeat}/types_test.go | 2 +- internal/heartbeat/utils.go | 3 +- internal/heartbeat/utils_test.go | 7 +- internal/history/types.go | 10 +- internal/history/types_test.go | 7 +- internal/notifier/dispatcher.go | 45 ++++++++- internal/notifier/dispatcher_test.go | 17 +++- internal/notifier/email.go | 12 ++- internal/notifier/msteams.go | 12 ++- internal/notifier/msteamsgraph.go | 12 ++- internal/notifier/msteamsgraph_test.go | 3 +- internal/notifier/slack.go | 17 ++-- internal/notifier/slack_test.go | 3 +- internal/notifier/types.go | 6 +- internal/routeutil/routeutil.go | 29 ------ internal/routeutil/routeutil_test.go | 38 ------- internal/server/prefix.go | 31 +++++- internal/server/prefix_test.go | 45 +++++++-- internal/server/routes.go | 13 +-- internal/server/routes_test.go | 33 ++++-- internal/service/bump/bump_test.go | 17 ++-- internal/service/test/test_test.go | 23 +++-- internal/utils/utils.go | 10 ++ internal/utils/utils_test.go | 49 +++++++++ internal/view/partials_test.go | 26 ++--- 46 files changed, 879 insertions(+), 412 deletions(-) create mode 100644 internal/config/reload_test.go create mode 100644 internal/handlers/reload.go create mode 100644 internal/handlers/reload_test.go rename internal/{common => heartbeat}/types.go (98%) rename internal/{common => heartbeat}/types_test.go (98%) delete mode 100644 internal/routeutil/routeutil.go delete mode 100644 internal/routeutil/routeutil_test.go create mode 100644 internal/utils/utils.go create mode 100644 internal/utils/utils_test.go diff --git a/README.md b/README.md index 8201f46..f275c43 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ A lightweight HTTP service for monitoring periodic "heartbeat" pings ("bumps") a | `/bump/{id}/fail` | `POST`, `GET` | Manually mark heartbeat as failed | | `/healthz` | `GET` | Liveness probe | | `/metrics` | `GET` | Prometheus metrics endpoint | +| `/-/reload` | `POST` | Reload configuration | + +## 🔄 Reloading Configuration + +Heartbeats supports hot-reload of the YAML config without restarting the process: + +- **Signal:** send `SIGHUP` to the process +- **HTTP:** `POST /-/reload` + +Reloading re-reads the config file, validates it, updates receiver configuration, and reconciles heartbeat actors. ## ⚙️ Configuration diff --git a/internal/app/run.go b/internal/app/run.go index ea1e3b5..b0ca048 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -29,8 +29,10 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string // Create a context to listen for shutdown signals ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() + // Create another context to listen for reload signals + reloadCh := make(chan os.Signal, 1) + signal.Notify(reloadCh, syscall.SIGHUP) - // Parse and validate command-line flags. flags, err := flag.ParseFlags(args, version) if err != nil { if tinyflags.IsHelpRequested(err) || tinyflags.IsVersionRequested(err) { @@ -40,7 +42,6 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string return fmt.Errorf("CLI flags error: %w", err) } - // Load and validate configuration. cfg, err := config.LoadConfig(flags.ConfigPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -49,24 +50,20 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string return fmt.Errorf("invalid YAML config: %w", err) } - // Setup logger logger := logging.SetupLogger(flags.LogFormat, flags.Debug, w) logger.Info("Starting Heartbeats", "version", version, "commit", commit) - // Log CLI overrides if len(flags.OverriddenValues) > 0 { logger.Info("CLI Overrides", "overrides", flags.OverriddenValues) } - // Create history cache - histStore, err := history.InitializeHistory(flags) + histStore, err := history.InitializeHistory(flags.HistorySize) if err != nil { return fmt.Errorf("failed to initialize history: %w", err) } histRecorder := servicehistory.NewRecorder(histStore) metricsReg := metrics.New(histStore) - // Inizalize notification store := notifier.InitializeStore(cfg.Receivers, flags.SkipTLS, version, logger) bufferSize := len(cfg.Heartbeats) // 1 slot per actor; each heartbeat sends max 1 notification concurrently dispatcher := notifier.NewDispatcher( @@ -82,36 +79,39 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string // Create Heartbeat Manager actorFactory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: histRecorder, - Metrics: metricsReg, - DispatchCh: dispatcher.Mailbox(), - }, + Logger: logger, + History: histRecorder, + Metrics: metricsReg, + DispatchCh: dispatcher.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( ctx, cfg.Heartbeats, - heartbeat.ManagerConfig{ - Logger: logger, - Factory: actorFactory, - }, + logger, + actorFactory, ) if err != nil { return fmt.Errorf("failed to initialize heartbeats: %w", err) } mgr.StartAll() + reloadConfig := config.NewReloadFunc(flags.ConfigPath, flags.SkipTLS, version, logger, dispatcher, mgr) + go config.WatchReload(ctx, reloadCh, logger, reloadConfig) + api := handlers.NewAPI( version, commit, webFS, + flags.SiteRoot, + flags.RoutePrefix, + flags.Debug, logger, mgr, histStore, histRecorder, dispatcher, metricsReg, + reloadConfig, ) // Run debug server if enabled @@ -122,12 +122,8 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string // Create server and run forever router := server.NewRouter( webFS, - flags.SiteRoot, - flags.RoutePrefix, - version, api, logger, - flags.Debug, ) if err := server.Run(ctx, flags.ListenAddr, router, logger); err != nil { return fmt.Errorf("failed to run Heartbeats: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index e86c13e..622e63d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,11 @@ package config import ( + "context" "fmt" + "log/slog" "os" + "sync" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/notifier" @@ -82,3 +85,76 @@ func (c *Config) Validate() error { } return nil } + +// Reload loads, validates, and applies the config to the dispatcher and manager. +func Reload( + filePath string, + skipTLS bool, + version string, + logger *slog.Logger, + dispatcher *notifier.Dispatcher, + mgr *heartbeat.Manager, +) (heartbeat.ReconcileResult, error) { + cfg, err := LoadConfig(filePath) + if err != nil { + return heartbeat.ReconcileResult{}, fmt.Errorf("failed to load config: %w", err) + } + if err := cfg.Validate(); err != nil { + return heartbeat.ReconcileResult{}, fmt.Errorf("invalid YAML config: %w", err) + } + + newStore := notifier.InitializeStore(cfg.Receivers, skipTLS, version, logger) + dispatcher.UpdateStore(newStore) + + res, err := mgr.Reconcile(cfg.Heartbeats) + if err != nil { + return res, err + } + return res, nil +} + +// NewReloadFunc builds a reload function with logging and serialization. +func NewReloadFunc( + filePath string, + skipTLS bool, + version string, + logger *slog.Logger, + dispatcher *notifier.Dispatcher, + mgr *heartbeat.Manager, +) func() error { + var reloadMu sync.Mutex + return func() error { + reloadMu.Lock() + defer reloadMu.Unlock() + + res, err := Reload(filePath, skipTLS, version, logger, dispatcher, mgr) + if err != nil { + return err + } + if res.Added+res.Updated+res.Removed > 0 { + logger.Info("heartbeats reloaded", + "added", res.Added, + "updated", res.Updated, + "removed", res.Removed, + ) + } + return nil + } +} + +// WatchReload listens for reload signals and invokes the reload function. +func WatchReload(ctx context.Context, reloadCh <-chan os.Signal, logger *slog.Logger, reloadFn func() error) { + for { + select { + case <-ctx.Done(): + return + case <-reloadCh: + logger.Info("reload requested") + if err := reloadFn(); err != nil { + logger.Error("reload failed", "err", err) + } else { + logger.Info("reload completed") + } + } + } +} diff --git a/internal/config/reload_test.go b/internal/config/reload_test.go new file mode 100644 index 0000000..46dfaf7 --- /dev/null +++ b/internal/config/reload_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "context" + "log/slog" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWatchReload(t *testing.T) { + t.Parallel() + + var buf strings.Builder + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + ch := make(chan os.Signal, 1) + done := make(chan struct{}) + + go WatchReload(ctx, ch, logger, func() error { + close(done) + return nil + }) + + ch <- os.Interrupt + + select { + case <-done: + // ok + case <-time.After(time.Second): + require.Fail(t, "timeout waiting for reload") + } +} diff --git a/internal/debugserver/debugserver_test.go b/internal/debugserver/debugserver_test.go index bf7d58f..64f6cd1 100644 --- a/internal/debugserver/debugserver_test.go +++ b/internal/debugserver/debugserver_test.go @@ -11,6 +11,7 @@ import ( "github.com/containeroo/heartbeats/internal/handlers" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/require" @@ -24,9 +25,10 @@ func TestDebugServer_Run(t *testing.T) { // Setup basic in-memory state logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(5) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.NewReceiverStore() - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1*time.Millisecond, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1*time.Millisecond, 10, metricsReg) hbCfg := map[string]heartbeat.HeartbeatConfig{ "test-hb": { @@ -37,20 +39,33 @@ func TestDebugServer_Run(t *testing.T) { }, } factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( ctx, hbCfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) require.NoError(t, err) - api := handlers.NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) + api := handlers.NewAPI( + "test", + "test", + nil, + "", + "", + true, + logger, + mgr, + hist, + recorder, + disp, + nil, + nil, + ) // Pick a random free port port := 8089 diff --git a/internal/flag/flag.go b/internal/flag/flag.go index c04b899..05affe0 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -7,7 +7,7 @@ import ( "time" "github.com/containeroo/heartbeats/internal/logging" - "github.com/containeroo/heartbeats/internal/routeutil" + "github.com/containeroo/heartbeats/internal/server" "github.com/containeroo/tinyflags" ) @@ -46,7 +46,7 @@ func ParseFlags(args []string, version string) (Options, error) { tf.StringVar(&opts.RoutePrefix, "route-prefix", "", "Path prefix to mount the app (e.g., /heartbeats). Empty = root."). Finalize(func(input string) string { - return routeutil.NormalizeRoutePrefix(input) // canonical "" or "/heartbeats" + return server.NormalizeRoutePrefix(input) // canonical "" or "/heartbeats" }). Placeholder("PATH"). Value() diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 4e7db1b..9010680 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -14,38 +14,50 @@ import ( // API bundles shared handler dependencies and runtime configuration. type API struct { - Version string // Version is the build version string. - Commit string // Commit is the build commit SHA. - webFS fs.FS - Logger *slog.Logger - mgr *heartbeat.Manager - hist history.Store - rec *servicehistory.Recorder - disp *notifier.Dispatcher - metrics *metrics.Registry + Version string // Version is the build version string. + Commit string // Commit is the build commit SHA. + webFS fs.FS + SiteRoot string + RoutePrefix string + Debug bool + Logger *slog.Logger + mgr *heartbeat.Manager + hist history.Store + rec *servicehistory.Recorder + disp *notifier.Dispatcher + metrics *metrics.Registry + reload func() error } // NewAPI builds a handler container with shared dependencies. func NewAPI( version, commit string, webFS fs.FS, + siteRoot string, + routePrefix string, + debug bool, logger *slog.Logger, mgr *heartbeat.Manager, hist history.Store, rec *servicehistory.Recorder, disp *notifier.Dispatcher, metricsReg *metrics.Registry, + configReloadFn func() error, ) *API { return &API{ - Version: version, - Commit: commit, - webFS: webFS, - Logger: logger, - mgr: mgr, - hist: hist, - rec: rec, - disp: disp, - metrics: metricsReg, + Version: version, + Commit: commit, + webFS: webFS, + SiteRoot: siteRoot, + RoutePrefix: routePrefix, + Debug: debug, + Logger: logger, + mgr: mgr, + hist: hist, + rec: rec, + disp: disp, + metrics: metricsReg, + reload: configReloadFn, } } diff --git a/internal/handlers/bump_test.go b/internal/handlers/bump_test.go index e8bb067..7eb7070 100644 --- a/internal/handlers/bump_test.go +++ b/internal/handlers/bump_test.go @@ -14,6 +14,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -35,22 +36,36 @@ func setupRouter(t *testing.T, hbName string, hist history.Store) http.Handler { store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + metricsReg := metrics.New(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( context.Background(), heartbeats, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) - api := NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) + api := NewAPI( + "test", + "test", + nil, + "", + "", + true, + logger, + mgr, + hist, + recorder, + disp, + nil, + nil, + ) router := http.NewServeMux() router.Handle("GET /no-id/", api.BumpHandler()) router.Handle("GET /no-id/fail", api.FailHandler()) diff --git a/internal/handlers/healthz_test.go b/internal/handlers/healthz_test.go index d5d27a1..6eee35e 100644 --- a/internal/handlers/healthz_test.go +++ b/internal/handlers/healthz_test.go @@ -15,7 +15,21 @@ import ( func TestHealthz(t *testing.T) { t.Parallel() - api := NewAPI("test", "test", nil, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + api := NewAPI( + "test", + "test", + nil, + "", + "", + true, + slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), + nil, + nil, + nil, + nil, + nil, + nil, + ) handler := api.Healthz(health.NewService()) req := httptest.NewRequest("GET", "/healthz", nil) diff --git a/internal/handlers/home_test.go b/internal/handlers/home_test.go index 9b7d218..a757f48 100644 --- a/internal/handlers/home_test.go +++ b/internal/handlers/home_test.go @@ -26,7 +26,21 @@ func TestHomeHandler(t *testing.T) { } version := "v1.2.3" - api := NewAPI(version, "test", webFS, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + api := NewAPI( + version, + "test", + webFS, + "", + "", + true, + slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), + nil, + nil, + nil, + nil, + nil, + nil, + ) handler := api.HomeHandler() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -56,7 +70,21 @@ func TestHomeHandler(t *testing.T) { } version := "v1.2.3" - api := NewAPI(version, "test", webFS, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, nil, nil, nil, nil) + api := NewAPI( + version, + "test", + webFS, + "", + "", + true, + slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), + nil, + nil, + nil, + nil, + nil, + nil, + ) handler := api.HomeHandler() req := httptest.NewRequest(http.MethodGet, "/", nil) diff --git a/internal/handlers/metrics_test.go b/internal/handlers/metrics_test.go index b912c67..0f5df6e 100644 --- a/internal/handlers/metrics_test.go +++ b/internal/handlers/metrics_test.go @@ -25,7 +25,22 @@ func TestMetricsHandler(t *testing.T) { // Create metrics handler with injected store metricsReg := metrics.New(store) - api := NewAPI("test", "test", nil, slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), nil, store, nil, nil, metricsReg) + api := NewAPI( + "test", + "test", + nil, + "", + "", + true, + slog.New(slog.NewTextHandler(&strings.Builder{}, nil)), + nil, + store, + nil, + nil, + metricsReg, + nil, + ) + handler := api.Metrics() req := httptest.NewRequest("GET", "/metrics", nil) diff --git a/internal/handlers/partials_test.go b/internal/handlers/partials_test.go index 272167d..0a92dff 100644 --- a/internal/handlers/partials_test.go +++ b/internal/handlers/partials_test.go @@ -13,6 +13,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -29,6 +30,7 @@ func TestPartialHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) _ = hist.Append(context.Background(), history.MustNewEvent( history.EventTypeHeartbeatReceived, "hb1", @@ -40,15 +42,13 @@ func TestPartialHandler(t *testing.T) { )) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "hb1": { @@ -57,9 +57,26 @@ func TestPartialHandler(t *testing.T) { Grace: 5 * time.Second, Receivers: []string{"r1"}, }, - }, heartbeat.ManagerConfig{Logger: logger, Factory: factory}) + }, + logger, + factory, + ) assert.NoError(t, err) - api := NewAPI("test", "test", webFS, logger, mgr, hist, recorder, disp, nil) + api := NewAPI( + "test", + "test", + webFS, + "", + "", + true, + logger, + mgr, + hist, + recorder, + disp, + nil, + nil, + ) t.Run("not found", func(t *testing.T) { t.Parallel() diff --git a/internal/handlers/reload.go b/internal/handlers/reload.go new file mode 100644 index 0000000..3f928ad --- /dev/null +++ b/internal/handlers/reload.go @@ -0,0 +1,18 @@ +package handlers + +import "net/http" + +// ReloadHandler triggers a config reload. +func (a *API) ReloadHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.reload == nil { + a.respondJSON(w, http.StatusNotImplemented, errorResponse{Error: "reload not configured"}) + return + } + if err := a.reload(); err != nil { + a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()}) + return + } + a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"}) + }) +} diff --git a/internal/handlers/reload_test.go b/internal/handlers/reload_test.go new file mode 100644 index 0000000..e606c27 --- /dev/null +++ b/internal/handlers/reload_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReloadHandler(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + + api := NewAPI( + "test", + "test", + nil, + "", + "", + false, + logger, + nil, + nil, + nil, + nil, + nil, + func() error { + calls.Add(1) + return nil + }, + ) + + req := httptest.NewRequest(http.MethodPost, "/-/reload", nil) + rec := httptest.NewRecorder() + api.ReloadHandler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var resp statusResponse + err := json.NewDecoder(rec.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Status) + assert.Equal(t, int32(1), calls.Load()) +} diff --git a/internal/handlers/test_test.go b/internal/handlers/test_test.go index dd72a3a..9212d07 100644 --- a/internal/handlers/test_test.go +++ b/internal/handlers/test_test.go @@ -13,6 +13,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -23,10 +24,25 @@ func TestTestReceiverHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) - api := NewAPI("test", "test", nil, logger, nil, hist, recorder, disp, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) + api := NewAPI( + "test", + "test", + nil, + "", + "", + true, + logger, + nil, + hist, + recorder, + disp, + nil, + nil, + ) handler := api.TestReceiverHandler() @@ -67,9 +83,10 @@ func TestTestHeartbeatHandler(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) hbName := "test-hb" cfg := heartbeat.HeartbeatConfigMap{ @@ -82,20 +99,33 @@ func TestTestHeartbeatHandler(t *testing.T) { }, } factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( context.Background(), cfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) - api := NewAPI("test", "test", nil, logger, mgr, hist, recorder, disp, nil) + api := NewAPI( + "test", + "test", + nil, + "", + "", + true, + logger, + mgr, + hist, + recorder, + disp, + nil, + nil, + ) handler := api.TestHeartbeatHandler() t.Run("missing id", func(t *testing.T) { diff --git a/internal/heartbeat/actor.go b/internal/heartbeat/actor.go index 4d2e645..2cce89e 100644 --- a/internal/heartbeat/actor.go +++ b/internal/heartbeat/actor.go @@ -5,7 +5,6 @@ import ( "log/slog" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" @@ -22,7 +21,7 @@ type Actor struct { Description string // human‐friendly description of this heartbeat Grace time.Duration // grace period before triggering an alert Receivers []string // list of receiver IDs to notify upon alerts - mailbox chan common.EventType // incoming event channel for this actor + mailbox chan EventType // incoming event channel for this actor logger *slog.Logger // structured logger scoped to this actor hist *servicehistory.Recorder // history store for recording events dispatchCh chan<- notifier.NotificationData // sends notifications to the dispatcher @@ -32,7 +31,7 @@ type Actor struct { graceTimer *time.Timer // timer for the grace period countdown delayTimer *time.Timer // timer for deferring transitions (e.g. active → grace) pending func() // next transition to run after delay - State common.HeartbeatState // current state (idle, active, grace, missing, etc.) + State HeartbeatState // current state (idle, active, grace, missing, etc.) } // ActorConfig holds all parameters required to construct a heartbeat Actor. @@ -57,17 +56,17 @@ func NewActorFromConfig(cfg ActorConfig) *Actor { Interval: cfg.Interval, Grace: cfg.Grace, Receivers: cfg.Receivers, - mailbox: make(chan common.EventType, 1), + mailbox: make(chan EventType, 1), logger: cfg.Logger, hist: cfg.History, dispatchCh: cfg.DispatchCh, metrics: cfg.Metrics, - State: common.HeartbeatStateIdle, + State: HeartbeatStateIdle, } } // Mailbox returns the actor's event channel. -func (a *Actor) Mailbox() chan<- common.EventType { return a.mailbox } +func (a *Actor) Mailbox() chan<- EventType { return a.mailbox } // Run starts the actor loop and handles incoming events and timers. func (a *Actor) Run(ctx context.Context) { @@ -93,23 +92,23 @@ func (a *Actor) Run(ctx context.Context) { case ev := <-a.mailbox: // handle heartbeat, manual failure and test switch ev { - case common.EventReceive: + case EventReceive: a.onReceive() - case common.EventFail: + case EventFail: a.onFail() - case common.EventTest: + case EventTest: a.onTest() } case <-checkCh: // missed expected ping → defer grace transition - if a.State == common.HeartbeatStateActive { + if a.State == HeartbeatStateActive { a.setPending(a.onEnterGrace) } case <-graceCh: // grace expired → defer missing transition - if a.State == common.HeartbeatStateGrace { + if a.State == HeartbeatStateGrace { a.setPending(a.onEnterMissing) } @@ -130,18 +129,18 @@ func (a *Actor) onReceive() { a.pending = nil // clear pending state change // if recovering from missing, send recovery notice - if prev == common.HeartbeatStateMissing { + if prev == HeartbeatStateMissing { // send notification a.dispatchCh <- notifier.NotificationData{ ID: a.ID, Description: a.Description, LastBump: now, - Status: common.HeartbeatStateRecovered.String(), + Status: HeartbeatStateRecovered.String(), Receivers: a.Receivers, } } - a.State = common.HeartbeatStateActive + a.State = HeartbeatStateActive if err := a.recordStateChange(prev, a.State); err != nil { a.logger.Error("failed to record state change", "err", err) } @@ -166,11 +165,11 @@ func (a *Actor) onFail() { ID: a.ID, Description: a.Description, LastBump: now, - Status: common.HeartbeatStateFailed.String(), + Status: HeartbeatStateFailed.String(), Receivers: a.Receivers, } - a.State = common.HeartbeatStateFailed + a.State = HeartbeatStateFailed if err := a.recordStateChange(prev, a.State); err != nil { a.logger.Error("failed to record state change", "err", err) } @@ -180,12 +179,12 @@ func (a *Actor) onFail() { // onEnterGrace transitions to Grace state. func (a *Actor) onEnterGrace() { - if a.State != common.HeartbeatStateActive { + if a.State != HeartbeatStateActive { return } prev := a.State - a.State = common.HeartbeatStateGrace + a.State = HeartbeatStateGrace if err := a.recordStateChange(prev, a.State); err != nil { a.logger.Error("failed to record state change", "err", err) } @@ -195,11 +194,11 @@ func (a *Actor) onEnterGrace() { // onEnterMissing transitions to Missing state and sends alert. func (a *Actor) onEnterMissing() { - if a.State != common.HeartbeatStateGrace { + if a.State != HeartbeatStateGrace { return } prev := a.State - a.State = common.HeartbeatStateMissing + a.State = HeartbeatStateMissing if err := a.recordStateChange(prev, a.State); err != nil { a.logger.Error("failed to record state change", "err", err) } @@ -209,7 +208,7 @@ func (a *Actor) onEnterMissing() { ID: a.ID, Description: a.Description, LastBump: a.LastBump, - Status: common.HeartbeatStateMissing.String(), + Status: HeartbeatStateMissing.String(), Receivers: a.Receivers, } diff --git a/internal/heartbeat/actor_test.go b/internal/heartbeat/actor_test.go index 18039d3..b21764d 100644 --- a/internal/heartbeat/actor_test.go +++ b/internal/heartbeat/actor_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -69,7 +69,9 @@ func TestActor_Run_Smoke(t *testing.T) { }, }) - disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, nil) + metricsReg := metrics.New(hist) + + disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, metricsReg) go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ @@ -81,11 +83,12 @@ func TestActor_Run_Smoke(t *testing.T) { Logger: logger, History: servicehistory.NewRecorder(hist), DispatchCh: disp.Mailbox(), + Metrics: metricsReg, }) go actor.Run(ctx) // First receive to transition out of idle - actor.Mailbox() <- common.EventReceive + actor.Mailbox() <- EventReceive timeout := transitionDelay + 150*time.Millisecond @@ -120,7 +123,8 @@ func TestActor_Run_Smoke(t *testing.T) { return nil }, }) - disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, nil) + metricsReg := metrics.New(hist) + disp := notifier.NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1, 10, metricsReg) go disp.Run(ctx) actor := NewActorFromConfig(ActorConfig{ @@ -132,10 +136,11 @@ func TestActor_Run_Smoke(t *testing.T) { Logger: logger, History: servicehistory.NewRecorder(hist), DispatchCh: disp.Mailbox(), + Metrics: metricsReg, }) go actor.Run(ctx) - actor.Mailbox() <- common.EventReceive + actor.Mailbox() <- EventReceive timeout := transitionDelay + 200*time.Millisecond assert.True(t, waitForPayloadEvent(t, hist, func(p history.StateChangePayload) bool { @@ -147,7 +152,7 @@ func TestActor_Run_Smoke(t *testing.T) { }, timeout), "expected Active → Grace") timeout += 500 * time.Millisecond // allow time before recover - actor.Mailbox() <- common.EventReceive + actor.Mailbox() <- EventReceive assert.True(t, waitForPayloadEvent(t, hist, func(p history.StateChangePayload) bool { return p.From == "grace" && p.To == "active" @@ -168,7 +173,7 @@ func TestActor_Run_Smoke(t *testing.T) { }, timeout)) timeout += time.Second // final recover - actor.Mailbox() <- common.EventReceive + actor.Mailbox() <- EventReceive assert.True(t, waitForPayloadEvent(t, hist, func(p history.StateChangePayload) bool { return p.From == "missing" && p.To == "active" @@ -186,6 +191,7 @@ func TestActor_OnReceive(t *testing.T) { dispatchCh := make(chan notifier.NotificationData, 1) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) a := &Actor{ ctx: ctx, @@ -193,22 +199,23 @@ func TestActor_OnReceive(t *testing.T) { Description: "Test Heartbeat", Interval: 100 * time.Millisecond, Grace: 50 * time.Millisecond, + metrics: metricsReg, Receivers: []string{"r1"}, logger: logger, hist: servicehistory.NewRecorder(hist), dispatchCh: dispatchCh, - State: common.HeartbeatStateMissing, + State: HeartbeatStateMissing, } a.onReceive() - assert.Equal(t, common.HeartbeatStateActive, a.State) + assert.Equal(t, HeartbeatStateActive, a.State) assert.WithinDuration(t, time.Now(), a.LastBump, 50*time.Millisecond) select { case msg := <-dispatchCh: assert.Equal(t, "hb1", msg.ID) - assert.Equal(t, common.HeartbeatStateRecovered.String(), msg.Status) + assert.Equal(t, HeartbeatStateRecovered.String(), msg.Status) default: t.Fatal("expected recovery notification to be sent") } @@ -225,23 +232,25 @@ func TestActor_OnReceive(t *testing.T) { dispatchCh := make(chan notifier.NotificationData, 1) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) a := &Actor{ ctx: ctx, ID: "hb2", Description: "Test Heartbeat", Interval: 100 * time.Millisecond, + metrics: metricsReg, Grace: 50 * time.Millisecond, Receivers: []string{"r2"}, logger: logger, hist: servicehistory.NewRecorder(hist), dispatchCh: dispatchCh, - State: common.HeartbeatStateIdle, + State: HeartbeatStateIdle, } a.onReceive() - assert.Equal(t, common.HeartbeatStateActive, a.State) + assert.Equal(t, HeartbeatStateActive, a.State) assert.WithinDuration(t, time.Now(), a.LastBump, 50*time.Millisecond) select { @@ -269,6 +278,7 @@ func TestActor_OnReceive(t *testing.T) { return errors.New("fail!") }, } + metricsReg := metrics.New(&mockHist) a := &Actor{ ctx: ctx, @@ -276,16 +286,17 @@ func TestActor_OnReceive(t *testing.T) { Description: "Test Heartbeat", Interval: 100 * time.Millisecond, Grace: 50 * time.Millisecond, + metrics: metricsReg, Receivers: []string{"r2"}, logger: logger, hist: servicehistory.NewRecorder(&mockHist), dispatchCh: dispatchCh, - State: common.HeartbeatStateIdle, + State: HeartbeatStateIdle, } a.onReceive() - assert.Equal(t, common.HeartbeatStateActive, a.State) + assert.Equal(t, HeartbeatStateActive, a.State) assert.Contains(t, buf.String(), "level=ERROR msg=\"failed to record state change\" err=fail!\n") }) } @@ -299,26 +310,28 @@ func TestActor_OnFail(t *testing.T) { recv := make(chan notifier.NotificationData, 1) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) act := &Actor{ ID: "x", Description: "test", Receivers: []string{"r1"}, - State: common.HeartbeatStateActive, + State: HeartbeatStateActive, dispatchCh: recv, hist: servicehistory.NewRecorder(hist), + metrics: metricsReg, logger: logger, } act.onFail() - assert.Equal(t, common.HeartbeatStateFailed, act.State) + assert.Equal(t, HeartbeatStateFailed, act.State) select { case n := <-recv: assert.Equal(t, "x", n.ID) assert.Equal(t, "test", n.Description) - assert.Equal(t, common.HeartbeatStateFailed.String(), n.Status) + assert.Equal(t, HeartbeatStateFailed.String(), n.Status) assert.Equal(t, []string{"r1"}, n.Receivers) assert.WithinDuration(t, time.Now(), n.LastBump, time.Second) case <-time.After(10 * time.Millisecond): @@ -339,6 +352,7 @@ func TestActor_OnFail(t *testing.T) { return errors.New("fail!") }, } + metricsReg := metrics.New(&mockHist) a := &Actor{ ctx: ctx, @@ -350,12 +364,13 @@ func TestActor_OnFail(t *testing.T) { logger: logger, hist: servicehistory.NewRecorder(&mockHist), dispatchCh: dispatchCh, - State: common.HeartbeatStateActive, + State: HeartbeatStateActive, + metrics: metricsReg, } a.onFail() - assert.Equal(t, common.HeartbeatStateFailed, a.State) + assert.Equal(t, HeartbeatStateFailed, a.State) assert.Contains(t, buf.String(), "level=ERROR msg=\"failed to record state change\" err=fail!\n") }) } @@ -368,20 +383,22 @@ func TestActor_OnEnterGrace(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) act := &Actor{ ID: "x", - State: common.HeartbeatStateActive, + State: HeartbeatStateActive, Grace: 50 * time.Millisecond, logger: logger, hist: servicehistory.NewRecorder(hist), - mailbox: make(chan common.EventType, 1), + mailbox: make(chan EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), + metrics: metricsReg, } act.onEnterGrace() - assert.Equal(t, common.HeartbeatStateGrace, act.State) + assert.Equal(t, HeartbeatStateGrace, act.State) assert.NotNil(t, act.graceTimer) }) @@ -390,20 +407,22 @@ func TestActor_OnEnterGrace(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) act := &Actor{ ID: "x", - State: common.HeartbeatStateIdle, + State: HeartbeatStateIdle, Grace: 50 * time.Millisecond, logger: logger, + metrics: metricsReg, hist: servicehistory.NewRecorder(hist), - mailbox: make(chan common.EventType, 1), + mailbox: make(chan EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), } act.onEnterGrace() - assert.Equal(t, common.HeartbeatStateIdle, act.State) + assert.Equal(t, HeartbeatStateIdle, act.State) assert.Nil(t, act.graceTimer) }) @@ -418,20 +437,22 @@ func TestActor_OnEnterGrace(t *testing.T) { return errors.New("fail!") }, } + metricsReg := metrics.New(&mockHist) act := &Actor{ ID: "x", - State: common.HeartbeatStateActive, + State: HeartbeatStateActive, Grace: 50 * time.Millisecond, logger: logger, hist: servicehistory.NewRecorder(&mockHist), - mailbox: make(chan common.EventType, 1), + mailbox: make(chan EventType, 1), dispatchCh: make(chan notifier.NotificationData, 1), + metrics: metricsReg, } act.onEnterGrace() - assert.Equal(t, common.HeartbeatStateGrace, act.State) + assert.Equal(t, HeartbeatStateGrace, act.State) assert.Contains(t, buf.String(), "level=ERROR msg=\"failed to record state change\" err=fail!\n") }) } @@ -445,30 +466,32 @@ func TestActor_OnEnterMissing(t *testing.T) { recv := make(chan notifier.NotificationData, 1) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) now := time.Now() act := &Actor{ ID: "x", Description: "test", - State: common.HeartbeatStateGrace, + State: HeartbeatStateGrace, LastBump: now, Receivers: []string{"r1"}, dispatchCh: recv, hist: servicehistory.NewRecorder(hist), logger: logger, + metrics: metricsReg, } act.onEnterMissing() - assert.Equal(t, common.HeartbeatStateMissing, act.State) + assert.Equal(t, HeartbeatStateMissing, act.State) select { case n := <-recv: assert.Equal(t, "x", n.ID) assert.Equal(t, "test", n.Description) assert.Equal(t, now, n.LastBump) - assert.Equal(t, common.HeartbeatStateMissing.String(), n.Status) + assert.Equal(t, HeartbeatStateMissing.String(), n.Status) assert.Equal(t, []string{"r1"}, n.Receivers) case <-time.After(10 * time.Millisecond): t.Fatal("expected notification not sent") @@ -481,18 +504,20 @@ func TestActor_OnEnterMissing(t *testing.T) { recv := make(chan notifier.NotificationData, 1) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) act := &Actor{ ID: "x", - State: common.HeartbeatStateActive, + State: HeartbeatStateActive, dispatchCh: recv, hist: servicehistory.NewRecorder(hist), logger: logger, + metrics: metricsReg, } act.onEnterMissing() - assert.Equal(t, common.HeartbeatStateActive, act.State) + assert.Equal(t, HeartbeatStateActive, act.State) select { case n := <-recv: @@ -512,18 +537,20 @@ func TestActor_OnEnterMissing(t *testing.T) { return errors.New("fail!") }, } + metricsReg := metrics.New(&mockHist) act := &Actor{ ID: "x", - State: common.HeartbeatStateGrace, + State: HeartbeatStateGrace, dispatchCh: make(chan notifier.NotificationData, 1), hist: servicehistory.NewRecorder(&mockHist), logger: logger, + metrics: metricsReg, } act.onEnterMissing() - assert.Equal(t, common.HeartbeatStateMissing, act.State) + assert.Equal(t, HeartbeatStateMissing, act.State) assert.Contains(t, buf.String(), "level=ERROR msg=\"failed to record state change\" err=fail!\n") }) } @@ -540,7 +567,7 @@ func TestActor_OnTest(t *testing.T) { ID: "test-hb", Description: "desc", LastBump: time.Now().Add(-5 * time.Minute), - State: common.HeartbeatStateIdle, + State: HeartbeatStateIdle, Receivers: []string{"r1", "r2"}, dispatchCh: dispatchCh, } @@ -554,7 +581,7 @@ func TestActor_OnTest(t *testing.T) { assert.Equal(t, a.LastBump, msg.LastBump) assert.Equal(t, "idle", msg.Status) assert.Equal(t, []string{"r1", "r2"}, msg.Receivers) - assert.Equal(t, common.HeartbeatStateIdle.String(), msg.Status) + assert.Equal(t, HeartbeatStateIdle.String(), msg.Status) default: t.Fatal("expected notification to be sent") } diff --git a/internal/heartbeat/factory.go b/internal/heartbeat/factory.go index eaf44ed..40738d2 100644 --- a/internal/heartbeat/factory.go +++ b/internal/heartbeat/factory.go @@ -1,6 +1,7 @@ package heartbeat import ( + "fmt" "log/slog" "github.com/containeroo/heartbeats/internal/metrics" @@ -13,30 +14,28 @@ type ActorFactory interface { Build(cfg HeartbeatConfig) (*Actor, error) } -// ActorDeps bundles shared dependencies for actors. -type ActorDeps struct { +// DefaultActorFactory builds actors using the shared dependencies. +type DefaultActorFactory struct { Logger *slog.Logger History *servicehistory.Recorder Metrics *metrics.Registry DispatchCh chan<- notifier.NotificationData } -// DefaultActorFactory builds actors using the shared dependencies. -type DefaultActorFactory struct { - Deps ActorDeps -} - // Build constructs a new Actor without starting it. func (f DefaultActorFactory) Build(cfg HeartbeatConfig) (*Actor, error) { + if f.Metrics == nil { + return nil, fmt.Errorf("metrics registry is required") + } return NewActorFromConfig(ActorConfig{ ID: cfg.ID, Description: cfg.Description, Interval: cfg.Interval, Grace: cfg.Grace, Receivers: cfg.Receivers, - Logger: f.Deps.Logger, - History: f.Deps.History, - DispatchCh: f.Deps.DispatchCh, - Metrics: f.Deps.Metrics, + Logger: f.Logger, + History: f.History, + DispatchCh: f.DispatchCh, + Metrics: f.Metrics, }), nil } diff --git a/internal/heartbeat/manager.go b/internal/heartbeat/manager.go index f1c2c3c..478eb56 100644 --- a/internal/heartbeat/manager.go +++ b/internal/heartbeat/manager.go @@ -5,19 +5,19 @@ import ( "fmt" "log/slog" "slices" + "sync" - "github.com/containeroo/heartbeats/internal/common" + "github.com/containeroo/heartbeats/internal/utils" ) // Manager routes HTTP pings to Actors. type Manager struct { - actors map[string]*managedActor - logger *slog.Logger - baseCtx context.Context - factory ActorFactory - validate func(HeartbeatConfig) error - postCreate func(HeartbeatConfig, *Actor) error - started bool + actors map[string]*managedActor + logger *slog.Logger + baseCtx context.Context + factory ActorFactory + started bool + mu sync.RWMutex } type managedActor struct { @@ -27,49 +27,30 @@ type managedActor struct { cancel context.CancelFunc } -// ManagerConfig configures manager construction and actor validation hooks. -type ManagerConfig struct { - Logger *slog.Logger - Factory ActorFactory - Validate func(HeartbeatConfig) error - PostCreate func(HeartbeatConfig, *Actor) error -} - // NewManagerFromHeartbeatMap creates a Manager from heartbeat config without starting actors. func NewManagerFromHeartbeatMap( ctx context.Context, heartbeatConfigs HeartbeatConfigMap, - cfg ManagerConfig, + logger *slog.Logger, + factory ActorFactory, ) (*Manager, error) { - if cfg.Factory == nil { + if factory == nil { return nil, fmt.Errorf("manager requires an actor factory") } m := &Manager{ - actors: make(map[string]*managedActor, len(heartbeatConfigs)), - logger: cfg.Logger, - baseCtx: ctx, - factory: cfg.Factory, - validate: cfg.Validate, - postCreate: cfg.PostCreate, + actors: make(map[string]*managedActor, len(heartbeatConfigs)), + logger: logger, + baseCtx: ctx, + factory: factory, } for id, hb := range heartbeatConfigs { - if hb.ID == "" { - hb.ID = id - } - if cfg.Validate != nil { - if err := cfg.Validate(hb); err != nil { - return nil, fmt.Errorf("invalid heartbeat %q: %w", id, err) - } - } - actor, err := cfg.Factory.Build(hb) + hb.ID = utils.DefaultIfZero(hb.ID, id) + + actor, err := factory.Build(hb) if err != nil { return nil, fmt.Errorf("build heartbeat %q: %w", id, err) } - if cfg.PostCreate != nil { - if err := cfg.PostCreate(hb, actor); err != nil { - return nil, fmt.Errorf("validate heartbeat %q: %w", id, err) - } - } + m.actors[id] = &managedActor{actor: actor, cfg: hb} } return m, nil @@ -77,6 +58,9 @@ func NewManagerFromHeartbeatMap( // StartAll starts all actors that are not running yet. func (m *Manager) StartAll() int { + m.mu.Lock() + defer m.mu.Unlock() + started := 0 for _, ma := range m.actors { if ma.started { @@ -87,6 +71,7 @@ func (m *Manager) StartAll() int { ma.started = true go ma.actor.Run(ctx) started++ + m.logger.Debug("started heartbeat", "id", ma.actor.ID) } if started > 0 { m.started = true @@ -96,6 +81,9 @@ func (m *Manager) StartAll() int { // List returns all configured heartbeats. func (m *Manager) List() map[string]*Actor { + m.mu.RLock() + defer m.mu.RUnlock() + result := make(map[string]*Actor, len(m.actors)) for id, ma := range m.actors { result[id] = ma.actor @@ -105,6 +93,9 @@ func (m *Manager) List() map[string]*Actor { // Get returns one heartbeat’s info by ID. func (m *Manager) Get(id string) *Actor { + m.mu.RLock() + defer m.mu.RUnlock() + ma, ok := m.actors[id] if !ok { return nil @@ -114,6 +105,9 @@ func (m *Manager) Get(id string) *Actor { // Reconcile updates the manager with a new config set without rebuilding everything. func (m *Manager) Reconcile(heartbeatConfigs HeartbeatConfigMap) (ReconcileResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res ReconcileResult for id, ma := range m.actors { @@ -133,18 +127,12 @@ func (m *Manager) Reconcile(heartbeatConfigs HeartbeatConfigMap) (ReconcileResul ma.cfg = hb continue } - if m.validate != nil { - if err := m.validate(hb); err != nil { - return res, fmt.Errorf("invalid heartbeat %q: %w", id, err) - } - } + actor, err := m.factory.Build(hb) if err != nil { return res, fmt.Errorf("build heartbeat %q: %w", id, err) } - if err := m.postCreate(hb, actor); err != nil { - return res, fmt.Errorf("validate heartbeat %q: %w", id, err) - } + m.stopManaged(ma) newActor := &managedActor{actor: actor, cfg: hb} if m.started { @@ -158,18 +146,10 @@ func (m *Manager) Reconcile(heartbeatConfigs HeartbeatConfigMap) (ReconcileResul continue } - if m.validate != nil { - if err := m.validate(hb); err != nil { - return res, fmt.Errorf("invalid heartbeat %q: %w", id, err) - } - } actor, err := m.factory.Build(hb) if err != nil { return res, fmt.Errorf("build heartbeat %q: %w", id, err) } - if err := m.postCreate(hb, actor); err != nil { - return res, fmt.Errorf("validate heartbeat %q: %w", id, err) - } newActor := &managedActor{actor: actor, cfg: hb} if m.started { @@ -194,31 +174,37 @@ type ReconcileResult struct { // Receive notifies the Actor with a heartbeat "receive" event. func (m *Manager) Receive(id string) error { + m.mu.RLock() a, ok := m.actors[id] + m.mu.RUnlock() if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.actor.Mailbox() <- common.EventReceive + a.actor.Mailbox() <- EventReceive return nil } // Fail notifies the Actor with a heartbeat "fail" event. func (m *Manager) Fail(id string) error { + m.mu.RLock() a, ok := m.actors[id] + m.mu.RUnlock() if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.actor.Mailbox() <- common.EventFail + a.actor.Mailbox() <- EventFail return nil } // Test sends a test notification event to the Actor. func (m *Manager) Test(id string) error { + m.mu.RLock() a, ok := m.actors[id] + m.mu.RUnlock() if !ok { return fmt.Errorf("heartbeat ID %q not found", id) } - a.actor.Mailbox() <- common.EventTest + a.actor.Mailbox() <- EventTest return nil } diff --git a/internal/heartbeat/manager_test.go b/internal/heartbeat/manager_test.go index b254d14..52f8093 100644 --- a/internal/heartbeat/manager_test.go +++ b/internal/heartbeat/manager_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -21,22 +21,22 @@ func newTestManager( cfg heartbeat.HeartbeatConfigMap, disp *notifier.Dispatcher, recorder *servicehistory.Recorder, + metricsReg *metrics.Registry, logger *slog.Logger, ) *heartbeat.Manager { t.Helper() factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( ctx, cfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) return mgr @@ -48,9 +48,10 @@ func TestManager_HandleReceive(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) t.Run("sends receive to known actor", func(t *testing.T) { t.Parallel() @@ -64,14 +65,14 @@ func TestManager_HandleReceive(t *testing.T) { }, } - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) mgr.StartAll() err := mgr.Receive("a1") assert.NoError(t, err) time.Sleep(10 * time.Millisecond) // allow actor to process mailbox a := mgr.Get("a1") - assert.Equal(t, common.HeartbeatStateActive, a.State) + assert.Equal(t, heartbeat.HeartbeatStateActive, a.State) }) t.Run("actor not found", func(t *testing.T) { @@ -79,7 +80,7 @@ func TestManager_HandleReceive(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) err := mgr.Receive("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -92,9 +93,10 @@ func TestManager_HandleFail(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) t.Run("sends fail to known actor", func(t *testing.T) { t.Parallel() @@ -108,14 +110,14 @@ func TestManager_HandleFail(t *testing.T) { }, } - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) mgr.StartAll() err := mgr.Fail("a1") assert.NoError(t, err) time.Sleep(10 * time.Millisecond) // allow actor to process mailbox a := mgr.Get("a1") - assert.Equal(t, common.HeartbeatStateFailed, a.State) + assert.Equal(t, heartbeat.HeartbeatStateFailed, a.State) }) t.Run("actor not found", func(t *testing.T) { @@ -123,7 +125,7 @@ func TestManager_HandleFail(t *testing.T) { cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) err := mgr.Fail("a1") assert.Error(t, err) assert.EqualError(t, err, "heartbeat ID \"a1\" not found") @@ -136,9 +138,10 @@ func TestManager_HandleTest(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) t.Run("sends test event to known actor", func(t *testing.T) { t.Parallel() @@ -152,21 +155,21 @@ func TestManager_HandleTest(t *testing.T) { }, } - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) mgr.StartAll() err := mgr.Test("a1") assert.NoError(t, err) time.Sleep(10 * time.Millisecond) // allow actor to process mailbox a := mgr.Get("a1") - assert.Equal(t, common.HeartbeatStateIdle, a.State) + assert.Equal(t, heartbeat.HeartbeatStateIdle, a.State) }) t.Run("returns error if actor not found", func(t *testing.T) { t.Parallel() cfg := map[string]heartbeat.HeartbeatConfig{} - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) err := mgr.Test("does-not-exist") assert.Error(t, err) @@ -180,9 +183,10 @@ func TestManager_Get(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, metricsReg) cfg := map[string]heartbeat.HeartbeatConfig{ "a1": { @@ -199,7 +203,7 @@ func TestManager_Get(t *testing.T) { }, } - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) result := mgr.List() assert.Len(t, result, 2) @@ -211,9 +215,10 @@ func TestManager_List(t *testing.T) { ctx := context.Background() logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(20) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, metricsReg) cfg := map[string]heartbeat.HeartbeatConfig{ "a1": { @@ -230,7 +235,7 @@ func TestManager_List(t *testing.T) { }, } - mgr := newTestManager(t, ctx, cfg, disp, recorder, logger) + mgr := newTestManager(t, ctx, cfg, disp, recorder, metricsReg, logger) t.Run("Get found", func(t *testing.T) { t.Parallel() diff --git a/internal/common/types.go b/internal/heartbeat/types.go similarity index 98% rename from internal/common/types.go rename to internal/heartbeat/types.go index cd54ffb..21b6263 100644 --- a/internal/common/types.go +++ b/internal/heartbeat/types.go @@ -1,4 +1,4 @@ -package common +package heartbeat // HeartbeatState represents the internal state of a heartbeat actor. type HeartbeatState string diff --git a/internal/common/types_test.go b/internal/heartbeat/types_test.go similarity index 98% rename from internal/common/types_test.go rename to internal/heartbeat/types_test.go index 97dd873..f9e2b1a 100644 --- a/internal/common/types_test.go +++ b/internal/heartbeat/types_test.go @@ -1,4 +1,4 @@ -package common +package heartbeat import ( "testing" diff --git a/internal/heartbeat/utils.go b/internal/heartbeat/utils.go index 09d7bb0..f09c2c2 100644 --- a/internal/heartbeat/utils.go +++ b/internal/heartbeat/utils.go @@ -3,7 +3,6 @@ package heartbeat import ( "time" - "github.com/containeroo/heartbeats/internal/common" servicehistory "github.com/containeroo/heartbeats/internal/service/history" ) @@ -40,7 +39,7 @@ func (a *Actor) stopAllTimers() { } // recordStateChange logs and records a state change if it actually changed. -func (a *Actor) recordStateChange(prev, next common.HeartbeatState) error { +func (a *Actor) recordStateChange(prev, next HeartbeatState) error { if prev == next { // avoid noisy logs when state hasn’t changed (e.g. repeated heartbeats in active state) return nil diff --git a/internal/heartbeat/utils_test.go b/internal/heartbeat/utils_test.go index 50103a5..6a46880 100644 --- a/internal/heartbeat/utils_test.go +++ b/internal/heartbeat/utils_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/internal/history" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -71,7 +70,7 @@ func TestRecordStateChange_ChangesState(t *testing.T) { hist: recorder, } - a.recordStateChange(common.HeartbeatStateGrace, common.HeartbeatStateActive) // nolint:errcheck + a.recordStateChange(HeartbeatStateGrace, HeartbeatStateActive) // nolint:errcheck assert.True(t, waitForPayloadEvent(t, hist, func(p history.StateChangePayload) bool { return p.From == "grace" && p.To == "active" @@ -93,7 +92,7 @@ func TestRecordStateChange_ChangesState(t *testing.T) { hist: recorder, } - a.recordStateChange(common.HeartbeatStateActive, common.HeartbeatStateActive) // nolint:errcheck + a.recordStateChange(HeartbeatStateActive, HeartbeatStateActive) // nolint:errcheck assert.Empty(t, logBuf.String()) }) @@ -117,7 +116,7 @@ func TestRecordStateChange_ChangesState(t *testing.T) { hist: recorder, } - a.recordStateChange(common.HeartbeatStateActive, common.HeartbeatStateActive) // nolint:errcheck + a.recordStateChange(HeartbeatStateActive, HeartbeatStateActive) // nolint:errcheck assert.Empty(t, logBuf.String()) }) diff --git a/internal/history/types.go b/internal/history/types.go index 0c2d8cf..ca4392d 100644 --- a/internal/history/types.go +++ b/internal/history/types.go @@ -1,10 +1,6 @@ package history -import ( - "context" - - "github.com/containeroo/heartbeats/internal/flag" -) +import "context" type BackendType string @@ -19,6 +15,6 @@ type Store interface { } // InitializeHistory initializes a new history store. -func InitializeHistory(flags flag.Options) (Store, error) { - return NewRingStore(flags.HistorySize), nil +func InitializeHistory(size int) (Store, error) { + return NewRingStore(size), nil } diff --git a/internal/history/types_test.go b/internal/history/types_test.go index 2de2378..8ae240f 100644 --- a/internal/history/types_test.go +++ b/internal/history/types_test.go @@ -3,8 +3,6 @@ package history import ( "testing" - "github.com/containeroo/heartbeats/internal/flag" - "github.com/stretchr/testify/assert" ) @@ -14,10 +12,7 @@ func TestInitHistory(t *testing.T) { t.Run("Ring backend returns store", func(t *testing.T) { t.Parallel() - flags := flag.Options{ - HistorySize: 5, - } - store, err := InitializeHistory(flags) + store, err := InitializeHistory(5) assert.NoError(t, err) assert.NotNil(t, store) }) diff --git a/internal/notifier/dispatcher.go b/internal/notifier/dispatcher.go index f0754b2..d141ec3 100644 --- a/internal/notifier/dispatcher.go +++ b/internal/notifier/dispatcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sync" "time" "github.com/containeroo/heartbeats/internal/metrics" @@ -13,6 +14,7 @@ import ( // Dispatcher handles queued notifications via mailbox. type Dispatcher struct { store *ReceiverStore // maps receiver IDs → []Notifier + storeMu sync.RWMutex // guards store history *servicehistory.Recorder // stores notifications logger *slog.Logger // logs any notify errors retries int // number of retries for notifications @@ -61,8 +63,12 @@ func (d *Dispatcher) Mailbox() chan<- NotificationData { return d.mailbox } // dispatch looks up receivers and sends notifications in parallel. func (d *Dispatcher) dispatch(ctx context.Context, data NotificationData) { + d.storeMu.RLock() + store := d.store + d.storeMu.RUnlock() + for _, rid := range data.Receivers { - notifiers := d.store.getNotifiers(rid) + notifiers := store.getNotifiers(rid) if len(notifiers) == 0 { d.logger.Warn("no notifiers for receiver", "receiver", rid) continue @@ -79,14 +85,25 @@ func (d *Dispatcher) dispatch(ctx context.Context, data NotificationData) { // List returns all configured notifiers. func (d *Dispatcher) List() map[string][]Notifier { + d.storeMu.RLock() + defer d.storeMu.RUnlock() return d.store.notifiers } // Get returns notifiers for a receiver. func (d *Dispatcher) Get(id string) []Notifier { + d.storeMu.RLock() + defer d.storeMu.RUnlock() return d.store.notifiers[id] } +// UpdateStore swaps the receiver store used by the dispatcher. +func (d *Dispatcher) UpdateStore(store *ReceiverStore) { + d.storeMu.Lock() + d.store = store + d.storeMu.Unlock() +} + // sendWithRetry retries a notification and records its outcome. func (d *Dispatcher) sendWithRetry(ctx context.Context, receiverID string, n Notifier, data NotificationData) { factory := servicehistory.NewFactory() @@ -99,7 +116,31 @@ func (d *Dispatcher) sendWithRetry(ctx context.Context, receiverID string, n Not eventType := servicehistory.EventTypeNotificationSent receiverStatus := metrics.SUCCESS - if err := d.retryNotify(ctx, n, data, receiverID); err != nil { + formatted := data + if f, ok := n.(Formatter); ok { + var err error + formatted, err = f.Format(data) + if err != nil { + payload.Error = err.Error() + + d.logger.Error("notification format error", + "receiver", receiverID, + "type", n.Type(), + "target", n.Target(), + "error", err, + ) + + d.metrics.SetReceiverStatus(receiverID, n.Type(), n.Target(), metrics.ERROR) + + ev := factory.NotificationFailed(data.ID, payload.Receiver, payload.Type, payload.Target, payload.Error) + if err := d.history.Append(ctx, ev); err != nil { + d.logger.Error("failed to record state change", "err", err) + } + return + } + } + + if err := d.retryNotify(ctx, n, formatted, receiverID); err != nil { payload.Error = err.Error() eventType = servicehistory.EventTypeNotificationFailed diff --git a/internal/notifier/dispatcher_test.go b/internal/notifier/dispatcher_test.go index d8056c3..811a702 100644 --- a/internal/notifier/dispatcher_test.go +++ b/internal/notifier/dispatcher_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" ) @@ -25,9 +26,10 @@ func TestDispatcher_Dispatch(t *testing.T) { store.Register("r1", n) hist := history.NewRingStore(10) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + metricsReg := metrics.New(hist) // Buffer size = 1 for test - dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, metricsReg) // Start dispatcher loop ctx := t.Context() @@ -59,7 +61,9 @@ func TestDispatcher_Dispatch(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := NewReceiverStore() hist := history.NewRingStore(10) - dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) + metricsReg := metrics.New(hist) + + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, metricsReg) // Start dispatcher loop ctx := t.Context() @@ -92,8 +96,9 @@ func TestDispatcher_Dispatch(t *testing.T) { return errors.New("fail!") }, } + metricsReg := metrics.New(&mockHist) - dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(&mockHist), 1, 1*time.Millisecond, 1, nil) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(&mockHist), 1, 1*time.Millisecond, 1, metricsReg) // Start dispatcher loop ctx := t.Context() @@ -128,9 +133,10 @@ func TestDispatcher_ListAndGet(t *testing.T) { store.Register("b", n1) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) // Added buffer size (doesn't matter for this test since we don't use the mailbox) - dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, nil) + dispatcher := NewDispatcher(store, logger, servicehistory.NewRecorder(hist), 1, 1*time.Millisecond, 1, metricsReg) t.Run("lists all receivers", func(t *testing.T) { t.Parallel() @@ -163,6 +169,7 @@ func TestDispatcher_LogsErrorFromNotifier(t *testing.T) { var logBuf strings.Builder logger := slog.New(slog.NewTextHandler(&logBuf, nil)) + metricsReg := metrics.New(&history.MockStore{}) dispatcher := NewDispatcher( store, @@ -171,7 +178,7 @@ func TestDispatcher_LogsErrorFromNotifier(t *testing.T) { 1, 1*time.Millisecond, 1, // buffer size - nil, + metricsReg, ) // Start dispatcher loop diff --git a/internal/notifier/email.go b/internal/notifier/email.go index 976b089..f38ed15 100644 --- a/internal/notifier/email.go +++ b/internal/notifier/email.go @@ -66,10 +66,14 @@ func (e *EmailConfig) Notify(ctx context.Context, data NotificationData) error { e.lastSent = time.Now() e.lastErr = nil - formatted, err := e.Format(data) - if err != nil { - e.lastErr = err - return fmt.Errorf("failed to format notification: %w", err) + formatted := data + if formatted.Title == "" || formatted.Message == "" { + var err error + formatted, err = e.Format(data) + if err != nil { + e.lastErr = err + return fmt.Errorf("failed to format notification: %w", err) + } } msg := email.Message{ diff --git a/internal/notifier/msteams.go b/internal/notifier/msteams.go index cf6cf63..7e9b60c 100644 --- a/internal/notifier/msteams.go +++ b/internal/notifier/msteams.go @@ -56,10 +56,14 @@ func (m *MSTeamsConfig) Notify(ctx context.Context, data NotificationData) error m.lastSent = time.Now() m.lastErr = nil - formatted, err := m.Format(data) - if err != nil { - m.lastErr = err - return fmt.Errorf("failed to format notification: %w", err) + formatted := data + if formatted.Title == "" || formatted.Message == "" { + var err error + formatted, err = m.Format(data) + if err != nil { + m.lastErr = err + return fmt.Errorf("failed to format notification: %w", err) + } } msg := msteams.MSTeams{ diff --git a/internal/notifier/msteamsgraph.go b/internal/notifier/msteamsgraph.go index 8688973..72f2cd5 100644 --- a/internal/notifier/msteamsgraph.go +++ b/internal/notifier/msteamsgraph.go @@ -61,10 +61,14 @@ func (m *MSTeamsGraphConfig) Notify(ctx context.Context, data NotificationData) m.lastSent = time.Now() m.lastErr = nil - formatted, err := m.Format(data) - if err != nil { - m.lastErr = err - return fmt.Errorf("format notification: %w", err) + formatted := data + if formatted.Title == "" || formatted.Message == "" { + var err error + formatted, err = m.Format(data) + if err != nil { + m.lastErr = err + return fmt.Errorf("format notification: %w", err) + } } body := msteamsgraph.ItemBody{ diff --git a/internal/notifier/msteamsgraph_test.go b/internal/notifier/msteamsgraph_test.go index e58126e..3f6c758 100644 --- a/internal/notifier/msteamsgraph_test.go +++ b/internal/notifier/msteamsgraph_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/pkg/notify/msteamsgraph" "github.com/stretchr/testify/assert" ) @@ -102,7 +101,7 @@ func TestMSTeamsGraphConfig_Notify(t *testing.T) { err := cfg.Notify(context.Background(), NotificationData{ ID: "check-1", - Status: common.HeartbeatStateRecovered.String(), + Status: "recovered", Description: "OK now", LastBump: time.Now().Add(-2 * time.Minute), }) diff --git a/internal/notifier/slack.go b/internal/notifier/slack.go index 0c64ec8..c368d57 100644 --- a/internal/notifier/slack.go +++ b/internal/notifier/slack.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/pkg/notify/slack" "github.com/containeroo/resolver" ) @@ -59,15 +58,19 @@ func (s *SlackConfig) Notify(ctx context.Context, data NotificationData) error { s.lastSent = time.Now() s.lastErr = nil - formatted, err := s.Format(data) - if err != nil { - s.lastErr = err - return fmt.Errorf("format notification: %w", err) + formatted := data + if formatted.Title == "" || formatted.Message == "" { + var err error + formatted, err = s.Format(data) + if err != nil { + s.lastErr = err + return fmt.Errorf("format notification: %w", err) + } } - status := common.HeartbeatState(formatted.Status) + status := strings.ToLower(formatted.Status) color := "danger" - if status == common.HeartbeatStateActive || status == common.HeartbeatStateRecovered { + if status == "active" || status == "recovered" { color = "good" } diff --git a/internal/notifier/slack_test.go b/internal/notifier/slack_test.go index 884e8f8..56f33e9 100644 --- a/internal/notifier/slack_test.go +++ b/internal/notifier/slack_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/common" "github.com/containeroo/heartbeats/pkg/notify/slack" "github.com/stretchr/testify/assert" ) @@ -110,7 +109,7 @@ func TestSlackConfig_Notify(t *testing.T) { data := NotificationData{ ID: "api-check", - Status: common.HeartbeatStateRecovered.String(), + Status: "recovered", Description: "Heartbeat failed", LastBump: time.Now().Add(-10 * time.Minute), } diff --git a/internal/notifier/types.go b/internal/notifier/types.go index 0745c3a..7dab091 100644 --- a/internal/notifier/types.go +++ b/internal/notifier/types.go @@ -36,7 +36,6 @@ type NotificationInfo struct { // Notifier defines methods for sending notifications. type Notifier interface { Notify(ctx context.Context, data NotificationData) error // Notify sends a notification. - Format(data NotificationData) (NotificationData, error) // Format formats the notification title and text. Validate() error // Validate checks whether the notifier is correctly configured. Resolve() error // Resolve performs any necessary resolution (e.g., secrets, tokens). LastErr() error // LastError reports whether the last notification attempt succeeded. @@ -44,3 +43,8 @@ type Notifier interface { Target() string // Target returns safely masked or display version LastSent() time.Time // LastSent returns the timestamp of the last notification attempt. } + +// Formatter optionally formats notifications before sending. +type Formatter interface { + Format(data NotificationData) (NotificationData, error) +} diff --git a/internal/routeutil/routeutil.go b/internal/routeutil/routeutil.go deleted file mode 100644 index bcf5c0c..0000000 --- a/internal/routeutil/routeutil.go +++ /dev/null @@ -1,29 +0,0 @@ -package routeutil - -import ( - "net/url" - "strings" -) - -// NormalizeRoutePrefix returns "" or "/prefix" from input, accepting raw paths or full URLs. -func NormalizeRoutePrefix(input string) string { - s := strings.TrimSpace(input) - if s == "" || s == "/" { - return "" - } - // If someone passes a full URL, keep only the .Path. - if strings.Contains(s, "://") { - if u, err := url.Parse(s); err == nil { - s = u.Path - } - } - s = strings.TrimSpace(s) - s = strings.TrimRight(s, "/") - if !strings.HasPrefix(s, "/") { - s = "/" + s - } - if s == "/" { - return "" - } - return s -} diff --git a/internal/routeutil/routeutil_test.go b/internal/routeutil/routeutil_test.go deleted file mode 100644 index 0082cd6..0000000 --- a/internal/routeutil/routeutil_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package routeutil - -import "testing" - -func TestNormalizeRoutePrefix(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - t.Parallel() - if got := NormalizeRoutePrefix(""); got != "" { - t.Fatalf("want '', got %q", got) - } - }) - t.Run("slash", func(t *testing.T) { - t.Parallel() - if got := NormalizeRoutePrefix("/"); got != "" { - t.Fatalf("want '', got %q", got) - } - }) - t.Run("no leading slash", func(t *testing.T) { - t.Parallel() - if got := NormalizeRoutePrefix("tiledash"); got != "/tiledash" { - t.Fatalf("want '/tiledash', got %q", got) - } - }) - t.Run("trailing slash", func(t *testing.T) { - t.Parallel() - if got := NormalizeRoutePrefix("/tiledash/"); got != "/tiledash" { - t.Fatalf("want '/tiledash', got %q", got) - } - }) - t.Run("full url", func(t *testing.T) { - t.Parallel() - if got := NormalizeRoutePrefix("https://x/y/z/"); got != "/y/z" { - t.Fatalf("want '/y/z', got %q", got) - } - }) -} diff --git a/internal/server/prefix.go b/internal/server/prefix.go index 9459127..6f84fc4 100644 --- a/internal/server/prefix.go +++ b/internal/server/prefix.go @@ -1,6 +1,33 @@ package server -import "net/http" +import ( + "net/http" + "net/url" + "strings" +) + +// NormalizeRoutePrefix returns "" or "/prefix" from input, accepting raw paths or full URLs. +func NormalizeRoutePrefix(input string) string { + s := strings.TrimSpace(input) + if s == "" || s == "/" { + return "" + } + // If someone passes a full URL, keep only the .Path. + if strings.Contains(s, "://") { + if u, err := url.Parse(s); err == nil { + s = u.Path + } + } + s = strings.TrimSpace(s) + s = strings.TrimRight(s, "/") + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + if s == "/" { + return "" + } + return s +} // mountUnderPrefix mounts h under the given route prefix, adding a redirect from bare prefix → prefix/. func mountUnderPrefix(h http.Handler, prefix string) http.Handler { @@ -9,7 +36,7 @@ func mountUnderPrefix(h http.Handler, prefix string) http.Handler { } mux := http.NewServeMux() - // Redirect bare "/tiledash" to "/tiledash/" so subtree handlers match. + // Redirect bare "/heartbeats" to "/heartbeats/" so subtree handlers match. mux.HandleFunc("GET "+prefix, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, prefix+"/", http.StatusMovedPermanently) }) diff --git a/internal/server/prefix_test.go b/internal/server/prefix_test.go index 0005fe1..75210a7 100644 --- a/internal/server/prefix_test.go +++ b/internal/server/prefix_test.go @@ -40,9 +40,9 @@ func TestMountUnderPrefix(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "root", rec.Body.String()) - // With empty prefix, "/tiledash/foo" is ALSO matched by "/" (catch-all) → 200 "root". + // With empty prefix, "/heartbeats/foo" is ALSO matched by "/" (catch-all) → 200 "root". rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/tiledash/foo", nil) + req2 := httptest.NewRequest(http.MethodGet, "/heartbeats/foo", nil) h.ServeHTTP(rec2, req2) require.Equal(t, http.StatusOK, rec2.Code) require.Equal(t, "root", rec2.Body.String()) @@ -51,31 +51,31 @@ func TestMountUnderPrefix(t *testing.T) { t.Run("bare prefix redirects to prefix with trailing slash", func(t *testing.T) { t.Parallel() - h := mountUnderPrefix(inner, "/tiledash") + h := mountUnderPrefix(inner, "/heartbeats") rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/tiledash", nil) // bare prefix without slash + req := httptest.NewRequest(http.MethodGet, "/heartbeats", nil) // bare prefix without slash h.ServeHTTP(rec, req) require.Equal(t, http.StatusMovedPermanently, rec.Code) - assert.Equal(t, "/tiledash/", rec.Header().Get("Location")) + assert.Equal(t, "/heartbeats/", rec.Header().Get("Location")) }) t.Run("prefixed paths are stripped and routed to inner handler", func(t *testing.T) { t.Parallel() - h := mountUnderPrefix(inner, "/tiledash") + h := mountUnderPrefix(inner, "/heartbeats") // Request under the prefix should be stripped and served by inner "/foo". rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/tiledash/foo", nil) + req := httptest.NewRequest(http.MethodGet, "/heartbeats/foo", nil) h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "foo", rec.Body.String()) // Deeper nested path under the prefix should also be routed correctly. rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/tiledash/api/v1/ok", nil) + req2 := httptest.NewRequest(http.MethodGet, "/heartbeats/api/v1/ok", nil) h.ServeHTTP(rec2, req2) require.Equal(t, http.StatusOK, rec2.Code) assert.Equal(t, "ok", rec2.Body.String()) @@ -84,12 +84,37 @@ func TestMountUnderPrefix(t *testing.T) { t.Run("non-prefixed paths 404 when mounted under a prefix", func(t *testing.T) { t.Parallel() - h := mountUnderPrefix(inner, "/tiledash") + h := mountUnderPrefix(inner, "/heartbeats") - // Direct root path should not be served when app is mounted under /tiledash only. + // Direct root path should not be served when app is mounted under /heartbeats only. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/foo", nil) h.ServeHTTP(rec, req) require.Equal(t, http.StatusNotFound, rec.Code) }) } + +func TestNormalizeRoutePrefix(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "", NormalizeRoutePrefix("")) + }) + t.Run("slash", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "", NormalizeRoutePrefix("/")) + }) + t.Run("no leading slash", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "/heartbeats", NormalizeRoutePrefix("heartbeats")) + }) + t.Run("trailing slash", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "/heartbeats", NormalizeRoutePrefix("/heartbeats/")) + }) + t.Run("full url", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "/y/z", NormalizeRoutePrefix("https://x/y/z")) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index a8005ec..09a0827 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -13,12 +13,8 @@ import ( // NewRouter creates a new HTTP router func NewRouter( webFS fs.FS, - siteRoot string, - routePrefix string, - version string, api *handlers.API, logger *slog.Logger, - debug bool, ) http.Handler { root := http.NewServeMux() @@ -28,9 +24,10 @@ func NewRouter( root.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) root.Handle("/", api.HomeHandler()) // no Method allowed, otherwise it crashes - root.Handle("GET /partials/", http.StripPrefix("/partials", api.PartialHandler(siteRoot))) + root.Handle("GET /partials/", http.StripPrefix("/partials", api.PartialHandler(api.SiteRoot))) root.Handle("GET /healthz", api.Healthz(health.NewService())) root.Handle("GET /metrics", api.Metrics()) + root.Handle("POST /-/reload", api.ReloadHandler()) // define your API routes on a sub-mux root.Handle("POST /bump/{id}", api.BumpHandler()) @@ -40,12 +37,12 @@ func NewRouter( // Mount the whole app under the prefix if provided var handler http.Handler = root - if routePrefix != "" { - handler = mountUnderPrefix(root, routePrefix) + if api.RoutePrefix != "" { + handler = mountUnderPrefix(root, api.RoutePrefix) } // wrap the whole mux in logging if debug - if debug { + if api.Debug { return middleware.Chain(handler, middleware.LoggingMiddleware(logger)) } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 86635c9..3603fce 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -51,25 +51,38 @@ func TestNewRouter(t *testing.T) { hist := history.NewRingStore(10) metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 0, 0, 10, metricsReg) factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( ctx, cfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) - api := handlers.NewAPI(version, "test", webFS, logger, mgr, hist, recorder, disp, metricsReg) - router := NewRouter(webFS, siteRoot, "", version, api, logger, true) + api := handlers.NewAPI( + version, + "test", + webFS, + siteRoot, + "", + true, + logger, + mgr, + hist, + recorder, + disp, + metricsReg, + nil, + ) + router := NewRouter(webFS, api, logger) t.Run("GET /", func(t *testing.T) { t.Parallel() diff --git a/internal/service/bump/bump_test.go b/internal/service/bump/bump_test.go index 9005ea8..3a81074 100644 --- a/internal/service/bump/bump_test.go +++ b/internal/service/bump/bump_test.go @@ -11,6 +11,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -22,7 +23,8 @@ func setupManager(t *testing.T, hist history.Store, hbName string) (*heartbeat.M logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.NewReceiverStore() recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + metricsReg := metrics.New(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) cfg := heartbeat.HeartbeatConfigMap{ hbName: { @@ -34,17 +36,16 @@ func setupManager(t *testing.T, hist history.Store, hbName string) (*heartbeat.M }, } factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( context.Background(), cfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) return mgr, recorder diff --git a/internal/service/test/test_test.go b/internal/service/test/test_test.go index dc939ff..cc5c5b4 100644 --- a/internal/service/test/test_test.go +++ b/internal/service/test/test_test.go @@ -9,9 +9,11 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSendTestNotification(t *testing.T) { @@ -30,8 +32,9 @@ func TestSendTestNotification(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -47,7 +50,7 @@ func TestSendTestNotification(t *testing.T) { assert.Equal(t, "This is a test notification", data.Message) assert.True(t, strings.HasPrefix(data.ID, "manual-test-")) case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for notification") + require.Fail(t, "timeout waiting for notification") } } @@ -58,7 +61,8 @@ func TestTriggerTestHeartbeat(t *testing.T) { hist := history.NewRingStore(10) store := notifier.NewReceiverStore() recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, nil) + metricsReg := metrics.New(hist) + disp := notifier.NewDispatcher(store, logger, recorder, 1, 1, 10, metricsReg) cfg := heartbeat.HeartbeatConfigMap{ "hb1": { @@ -70,17 +74,16 @@ func TestTriggerTestHeartbeat(t *testing.T) { }, } factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: logger, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: logger, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap( context.Background(), cfg, - heartbeat.ManagerConfig{Logger: logger, Factory: factory}, + logger, + factory, ) assert.NoError(t, err) diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..d3ff2dc --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,10 @@ +package utils + +// DefaultIfZero returns fallback when value is the zero value. +func DefaultIfZero[T comparable](value, fallback T) T { + var zero T + if value == zero { + return fallback + } + return value +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..da4964d --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultIfZero(t *testing.T) { + t.Parallel() + t.Run("String uses fallback on empty", func(t *testing.T) { + t.Parallel() + result := DefaultIfZero("", "fallback") + assert.Equal(t, "fallback", result) + }) + + t.Run("String keeps value", func(t *testing.T) { + t.Parallel() + result := DefaultIfZero("value", "fallback") + assert.Equal(t, "value", result) + }) + + t.Run("Int uses fallback on zero", func(t *testing.T) { + t.Parallel() + result := DefaultIfZero(0, 42) + assert.Equal(t, 42, result) + }) + + t.Run("Int keeps value", func(t *testing.T) { + t.Parallel() + result := DefaultIfZero(7, 42) + assert.Equal(t, 7, result) + }) + + t.Run("Time uses fallback on zero", func(t *testing.T) { + t.Parallel() + now := time.Now() + result := DefaultIfZero(time.Time{}, now) + assert.Equal(t, now, result) + }) + + t.Run("Time keeps value", func(t *testing.T) { + t.Parallel() + now := time.Now() + result := DefaultIfZero(now, time.Now().Add(time.Minute)) + assert.Equal(t, now, result) + }) +} diff --git a/internal/view/partials_test.go b/internal/view/partials_test.go index cf6af59..94ffe24 100644 --- a/internal/view/partials_test.go +++ b/internal/view/partials_test.go @@ -13,6 +13,7 @@ import ( "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/metrics" "github.com/containeroo/heartbeats/internal/notifier" servicehistory "github.com/containeroo/heartbeats/internal/service/history" "github.com/stretchr/testify/assert" @@ -36,9 +37,7 @@ func loadTestTemplate(t *testing.T, name string, content string) *template.Templ func encodePayload(t *testing.T, payload any) json.RawMessage { t.Helper() data, err := json.Marshal(payload) - if err != nil { - t.Fatalf("failed to marshal payload: %v", err) - } + assert.NoError(t, err) return data } @@ -48,17 +47,16 @@ func TestRenderHeartbeats(t *testing.T) { tmpl := loadTestTemplate(t, "heartbeats", `{{define "heartbeats"}}{{range .Heartbeats}}{{.ID}}:{{.Status}};{{end}}{{end}}`) hist := history.NewRingStore(10) + metricsReg := metrics.New(hist) logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(nil, false, "0.0.0", logger) recorder := servicehistory.NewRecorder(hist) - disp := notifier.NewDispatcher(store, nil, recorder, 1, 1, 10, nil) + disp := notifier.NewDispatcher(store, nil, recorder, 1, 1, 10, metricsReg) factory := heartbeat.DefaultActorFactory{ - Deps: heartbeat.ActorDeps{ - Logger: nil, - History: recorder, - Metrics: nil, - DispatchCh: disp.Mailbox(), - }, + Logger: nil, + History: recorder, + Metrics: metricsReg, + DispatchCh: disp.Mailbox(), } mgr, err := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ "b": { @@ -73,7 +71,10 @@ func TestRenderHeartbeats(t *testing.T) { Grace: 1 * time.Second, Receivers: []string{"r1"}, }, - }, heartbeat.ManagerConfig{Factory: factory}) + }, + logger, + factory, + ) assert.NoError(t, err) var buf bytes.Buffer @@ -99,7 +100,8 @@ func TestRenderReceivers(t *testing.T) { logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) store := notifier.InitializeStore(rc, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, nil, servicehistory.NewRecorder(nil), 1, 1, 10, nil) + metricsReg := metrics.New(history.NewRingStore(1)) + disp := notifier.NewDispatcher(store, nil, servicehistory.NewRecorder(nil), 1, 1, 10, metricsReg) var buf bytes.Buffer err := RenderReceivers(&buf, tmpl, disp) From 34110e559a998dfd9b4daed2cfd0cc0e8c09129f Mon Sep 17 00:00:00 2001 From: gi8 Date: Mon, 19 Jan 2026 14:37:01 +0100 Subject: [PATCH 4/4] rename package handlers to handler --- internal/app/run.go | 4 ++-- internal/debugserver/debugserver.go | 4 ++-- internal/debugserver/debugserver_test.go | 4 ++-- internal/{handlers => handler}/api.go | 2 +- internal/{handlers => handler}/bump.go | 2 +- internal/{handlers => handler}/bump_test.go | 2 +- internal/{handlers => handler}/healthz.go | 2 +- internal/{handlers => handler}/healthz_test.go | 2 +- internal/{handlers => handler}/home.go | 2 +- internal/{handlers => handler}/home_test.go | 2 +- internal/{handlers => handler}/metrics.go | 2 +- internal/{handlers => handler}/metrics_test.go | 2 +- internal/{handlers => handler}/partials.go | 2 +- internal/{handlers => handler}/partials_test.go | 2 +- internal/{handlers => handler}/reload.go | 2 +- internal/{handlers => handler}/reload_test.go | 2 +- internal/{handlers => handler}/test.go | 2 +- internal/{handlers => handler}/test_test.go | 2 +- internal/{handlers => handler}/utils.go | 2 +- internal/server/routes.go | 4 ++-- internal/server/routes_test.go | 4 ++-- 21 files changed, 26 insertions(+), 26 deletions(-) rename internal/{handlers => handler}/api.go (99%) rename internal/{handlers => handler}/bump.go (98%) rename internal/{handlers => handler}/bump_test.go (99%) rename internal/{handlers => handler}/healthz.go (95%) rename internal/{handlers => handler}/healthz_test.go (97%) rename internal/{handlers => handler}/home.go (97%) rename internal/{handlers => handler}/home_test.go (99%) rename internal/{handlers => handler}/metrics.go (88%) rename internal/{handlers => handler}/metrics_test.go (98%) rename internal/{handlers => handler}/partials.go (98%) rename internal/{handlers => handler}/partials_test.go (99%) rename internal/{handlers => handler}/reload.go (96%) rename internal/{handlers => handler}/reload_test.go (98%) rename internal/{handlers => handler}/test.go (98%) rename internal/{handlers => handler}/test_test.go (99%) rename internal/{handlers => handler}/utils.go (97%) diff --git a/internal/app/run.go b/internal/app/run.go index b0ca048..9dfa850 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -12,7 +12,7 @@ import ( "github.com/containeroo/heartbeats/internal/config" "github.com/containeroo/heartbeats/internal/debugserver" "github.com/containeroo/heartbeats/internal/flag" - "github.com/containeroo/heartbeats/internal/handlers" + "github.com/containeroo/heartbeats/internal/handler" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/logging" @@ -98,7 +98,7 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string reloadConfig := config.NewReloadFunc(flags.ConfigPath, flags.SkipTLS, version, logger, dispatcher, mgr) go config.WatchReload(ctx, reloadCh, logger, reloadConfig) - api := handlers.NewAPI( + api := handler.NewAPI( version, commit, webFS, diff --git a/internal/debugserver/debugserver.go b/internal/debugserver/debugserver.go index 8dd088e..6206149 100644 --- a/internal/debugserver/debugserver.go +++ b/internal/debugserver/debugserver.go @@ -5,11 +5,11 @@ import ( "fmt" "net/http" - "github.com/containeroo/heartbeats/internal/handlers" + "github.com/containeroo/heartbeats/internal/handler" ) // Run starts the local-only debug server for manual testing. -func Run(ctx context.Context, port int, api *handlers.API) { +func Run(ctx context.Context, port int, api *handler.API) { mux := http.NewServeMux() mux.Handle("GET /internal/receiver/{id}", api.TestReceiverHandler()) diff --git a/internal/debugserver/debugserver_test.go b/internal/debugserver/debugserver_test.go index 64f6cd1..879c116 100644 --- a/internal/debugserver/debugserver_test.go +++ b/internal/debugserver/debugserver_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/containeroo/heartbeats/internal/handlers" + "github.com/containeroo/heartbeats/internal/handler" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/metrics" @@ -51,7 +51,7 @@ func TestDebugServer_Run(t *testing.T) { factory, ) require.NoError(t, err) - api := handlers.NewAPI( + api := handler.NewAPI( "test", "test", nil, diff --git a/internal/handlers/api.go b/internal/handler/api.go similarity index 99% rename from internal/handlers/api.go rename to internal/handler/api.go index 9010680..78d5df9 100644 --- a/internal/handlers/api.go +++ b/internal/handler/api.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "io/fs" diff --git a/internal/handlers/bump.go b/internal/handler/bump.go similarity index 98% rename from internal/handlers/bump.go rename to internal/handler/bump.go index ee4cc9a..c939429 100644 --- a/internal/handlers/bump.go +++ b/internal/handler/bump.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "errors" diff --git a/internal/handlers/bump_test.go b/internal/handler/bump_test.go similarity index 99% rename from internal/handlers/bump_test.go rename to internal/handler/bump_test.go index 7eb7070..c5b6f06 100644 --- a/internal/handlers/bump_test.go +++ b/internal/handler/bump_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "context" diff --git a/internal/handlers/healthz.go b/internal/handler/healthz.go similarity index 95% rename from internal/handlers/healthz.go rename to internal/handler/healthz.go index f037212..037ff64 100644 --- a/internal/handlers/healthz.go +++ b/internal/handler/healthz.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "net/http" diff --git a/internal/handlers/healthz_test.go b/internal/handler/healthz_test.go similarity index 97% rename from internal/handlers/healthz_test.go rename to internal/handler/healthz_test.go index 6eee35e..6ef83e6 100644 --- a/internal/handlers/healthz_test.go +++ b/internal/handler/healthz_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "encoding/json" diff --git a/internal/handlers/home.go b/internal/handler/home.go similarity index 97% rename from internal/handlers/home.go rename to internal/handler/home.go index 486fe39..f6c6d8c 100644 --- a/internal/handlers/home.go +++ b/internal/handler/home.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "html/template" diff --git a/internal/handlers/home_test.go b/internal/handler/home_test.go similarity index 99% rename from internal/handlers/home_test.go rename to internal/handler/home_test.go index a757f48..8a0e05f 100644 --- a/internal/handlers/home_test.go +++ b/internal/handler/home_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "encoding/json" diff --git a/internal/handlers/metrics.go b/internal/handler/metrics.go similarity index 88% rename from internal/handlers/metrics.go rename to internal/handler/metrics.go index 88a81d6..780d9c8 100644 --- a/internal/handlers/metrics.go +++ b/internal/handler/metrics.go @@ -1,4 +1,4 @@ -package handlers +package handler import "net/http" diff --git a/internal/handlers/metrics_test.go b/internal/handler/metrics_test.go similarity index 98% rename from internal/handlers/metrics_test.go rename to internal/handler/metrics_test.go index 0f5df6e..d850c7c 100644 --- a/internal/handlers/metrics_test.go +++ b/internal/handler/metrics_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "context" diff --git a/internal/handlers/partials.go b/internal/handler/partials.go similarity index 98% rename from internal/handlers/partials.go rename to internal/handler/partials.go index 90336c1..e5786ee 100644 --- a/internal/handlers/partials.go +++ b/internal/handler/partials.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "fmt" diff --git a/internal/handlers/partials_test.go b/internal/handler/partials_test.go similarity index 99% rename from internal/handlers/partials_test.go rename to internal/handler/partials_test.go index 0a92dff..b6a7341 100644 --- a/internal/handlers/partials_test.go +++ b/internal/handler/partials_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "context" diff --git a/internal/handlers/reload.go b/internal/handler/reload.go similarity index 96% rename from internal/handlers/reload.go rename to internal/handler/reload.go index 3f928ad..088fd93 100644 --- a/internal/handlers/reload.go +++ b/internal/handler/reload.go @@ -1,4 +1,4 @@ -package handlers +package handler import "net/http" diff --git a/internal/handlers/reload_test.go b/internal/handler/reload_test.go similarity index 98% rename from internal/handlers/reload_test.go rename to internal/handler/reload_test.go index e606c27..3c6277a 100644 --- a/internal/handlers/reload_test.go +++ b/internal/handler/reload_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "encoding/json" diff --git a/internal/handlers/test.go b/internal/handler/test.go similarity index 98% rename from internal/handlers/test.go rename to internal/handler/test.go index 0065359..fe6cf44 100644 --- a/internal/handlers/test.go +++ b/internal/handler/test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "net/http" diff --git a/internal/handlers/test_test.go b/internal/handler/test_test.go similarity index 99% rename from internal/handlers/test_test.go rename to internal/handler/test_test.go index 9212d07..b9049e7 100644 --- a/internal/handlers/test_test.go +++ b/internal/handler/test_test.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "context" diff --git a/internal/handlers/utils.go b/internal/handler/utils.go similarity index 97% rename from internal/handlers/utils.go rename to internal/handler/utils.go index 7c9b507..9617ba0 100644 --- a/internal/handlers/utils.go +++ b/internal/handler/utils.go @@ -1,4 +1,4 @@ -package handlers +package handler import ( "encoding/json" diff --git a/internal/server/routes.go b/internal/server/routes.go index 09a0827..174c89e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -5,7 +5,7 @@ import ( "log/slog" "net/http" - "github.com/containeroo/heartbeats/internal/handlers" + "github.com/containeroo/heartbeats/internal/handler" "github.com/containeroo/heartbeats/internal/middleware" "github.com/containeroo/heartbeats/internal/service/health" ) @@ -13,7 +13,7 @@ import ( // NewRouter creates a new HTTP router func NewRouter( webFS fs.FS, - api *handlers.API, + api *handler.API, logger *slog.Logger, ) http.Handler { root := http.NewServeMux() diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 3603fce..acde0b4 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -11,7 +11,7 @@ import ( "testing/fstest" "time" - "github.com/containeroo/heartbeats/internal/handlers" + "github.com/containeroo/heartbeats/internal/handler" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/metrics" @@ -67,7 +67,7 @@ func TestNewRouter(t *testing.T) { ) assert.NoError(t, err) - api := handlers.NewAPI( + api := handler.NewAPI( version, "test", webFS,