Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions internal/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string
}

logger := logging.SetupLogger(flags.LogFormat, flags.Debug, w)
logger.Info("Starting Heartbeats", "version", version, "commit", commit)
logging.SystemLogger(logger, nil).Info("Starting Heartbeats", "version", version, "commit", commit)

if len(flags.OverriddenValues) > 0 {
logger.Info("CLI Overrides", "overrides", flags.OverriddenValues)
logging.SystemLogger(logger, nil).Info("CLI Overrides", "overrides", flags.OverriddenValues)
}

histStore, err := history.InitializeHistory(flags.HistorySize)
Expand Down Expand Up @@ -120,11 +120,10 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string
}

// Create server and run forever
router := server.NewRouter(
webFS,
api,
logger,
)
router, err := server.NewRouter(webFS, flags.RoutePrefix, api, flags.Debug)
if err != nil {
return fmt.Errorf("configure router: %w", err)
}
if err := server.Run(ctx, flags.ListenAddr, router, logger); err != nil {
return fmt.Errorf("failed to run Heartbeats: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/app/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ heartbeats:

assert.True(t, waitForLog(t, &buf, `"from":"active","to":"grace"`, 2*time.Second), "expected Active → Grace")
assert.True(t, waitForLog(t, &buf, `"from":"grace","to":"missing"`, 2*time.Second), "expected Grace → Missing")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":1`, 2*time.Second), "expected retry 1")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":2`, 2*time.Second), "expected retry 2")
assert.True(t, waitForLog(t, &buf, `notification failed after 2 retries`, 2*time.Second), "expected failure log")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":1`, 5*time.Second), "expected retry 1")
assert.True(t, waitForLog(t, &buf, `"msg":"retrying","attempt":2`, 5*time.Second), "expected retry 2")
assert.True(t, waitForLog(t, &buf, `notification failed after 2 retries`, 5*time.Second), "expected failure log")
})
}
9 changes: 5 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync"

"github.com/containeroo/heartbeats/internal/heartbeat"
"github.com/containeroo/heartbeats/internal/logging"
"github.com/containeroo/heartbeats/internal/notifier"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -132,7 +133,7 @@ func NewReloadFunc(
return err
}
if res.Added+res.Updated+res.Removed > 0 {
logger.Info("heartbeats reloaded",
logging.SystemLogger(logger, nil).Info("heartbeats reloaded",
"added", res.Added,
"updated", res.Updated,
"removed", res.Removed,
Expand All @@ -149,11 +150,11 @@ func WatchReload(ctx context.Context, reloadCh <-chan os.Signal, logger *slog.Lo
case <-ctx.Done():
return
case <-reloadCh:
logger.Info("reload requested")
logging.SystemLogger(logger, nil).Info("reload requested")
if err := reloadFn(); err != nil {
logger.Error("reload failed", "err", err)
logging.SystemLogger(logger, nil).Error("reload failed", "err", err)
} else {
logger.Info("reload completed")
logging.SystemLogger(logger, nil).Info("reload completed")
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions internal/debugserver/debugserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"

"github.com/containeroo/heartbeats/internal/handler"
"github.com/containeroo/heartbeats/internal/logging"
)

// Run starts the local-only debug server for manual testing.
Expand All @@ -16,7 +17,7 @@ func Run(ctx context.Context, port int, api *handler.API) {
mux.Handle("GET /internal/heartbeat/{id}", api.TestHeartbeatHandler())

addr := fmt.Sprintf("127.0.0.1:%d", port)
api.Logger.Info("starting debug server", "listenAddr", addr)
logging.SystemLogger(api.Logger, nil).Info("starting debug server", "listenAddr", addr)

server := &http.Server{
Addr: addr,
Expand All @@ -32,7 +33,7 @@ func Run(ctx context.Context, port int, api *handler.API) {
// Serve requests on 127.0.0.1 until shutdown.
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
api.Logger.Error("debug server error", "error", err)
logging.SystemLogger(api.Logger, nil).Error("debug server error", "error", err)
}
}()
}
21 changes: 20 additions & 1 deletion internal/handler/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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"
servicehistory "github.com/containeroo/heartbeats/internal/service/history"
Expand Down Expand Up @@ -64,10 +65,28 @@ func NewAPI(
// 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)
logging.AccessLogger(a.Logger, nil).Error(
"encode response failed",
"event", "encode_response_failed",
"err", err,
)
}
}

// businessLogger returns a logger enriched with request context for business events.
func (a *API) businessLogger(r *http.Request) *slog.Logger {
return logging.BusinessLogger(a.Logger, r.Context())
}

// logRequestError records a structured error for the current request.
func (a *API) logRequestError(r *http.Request, event, message string, err error) {
logging.AccessLogger(a.Logger, r.Context()).Error(
message,
"event", event,
"err", err,
)
}

// statusResponse is the standard success payload.
type statusResponse struct {
Status string `json:"status"`
Expand Down
8 changes: 8 additions & 0 deletions internal/handler/bump.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,23 @@ func (a *API) BumpHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
a.logRequestError(r, "missing_id", "missing id", errors.New("missing id"))
a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"})
return
}

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) {
a.logRequestError(r, "unknown_heartbeat", "unknown heartbeat id", err)
a.respondJSON(w, http.StatusNotFound, errorResponse{Error: fmt.Sprintf("unknown heartbeat id %q", id)})
return
}
a.logRequestError(r, "failed_to_receive", "failed to receive heartbeat", err)
a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
return
}

a.businessLogger(r).Info("received heartbeat", "id", id)
a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"})
})
}
Expand All @@ -35,19 +39,23 @@ func (a *API) FailHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
a.logRequestError(r, "missing_id", "missing id", errors.New("missing id"))
a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"})
return
}

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) {
a.logRequestError(r, "unknown_heartbeat", "unknown heartbeat id", err)
a.respondJSON(w, http.StatusNotFound, errorResponse{Error: fmt.Sprintf("unknown heartbeat id %q", id)})
return
}
a.logRequestError(r, "failed_to_fail", "failed to fail heartbeat", err)
a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
return
}

a.businessLogger(r).Info("failed heartbeat", "id", id)
a.respondJSON(w, http.StatusOK, statusResponse{Status: "ok"})
})
}
1 change: 1 addition & 0 deletions internal/handler/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func (a *API) HomeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// execute the "base" template
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
a.logRequestError(r, "render_home_failed", "render home failed", err)
a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/partials.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func (a *API) PartialHandler(
}

if err != nil {
a.Logger.Error("render "+section+" partial", "error", err)
a.logRequestError(r, "render_partial_failed", "render "+section+" partial", err)
a.respondJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal error"})
}
}
Expand Down
5 changes: 4 additions & 1 deletion internal/handler/test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"errors"
"net/http"

testservice "github.com/containeroo/heartbeats/internal/service/test"
Expand All @@ -11,6 +12,7 @@ func (a *API) TestReceiverHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
a.logRequestError(r, "missing_id", "missing id", errors.New("missing id"))
a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"})
return
}
Expand All @@ -26,12 +28,13 @@ func (a *API) TestHeartbeatHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
a.logRequestError(r, "missing_id", "missing id", errors.New("missing id"))
a.respondJSON(w, http.StatusBadRequest, errorResponse{Error: "missing id"})
return
}

if err := testservice.TriggerTestHeartbeat(a.mgr, a.Logger, id); err != nil {
a.Logger.Error("handle test failed", "id", id, "err", err)
a.logRequestError(r, "handle_test_failed", "handle test failed", err)
a.respondJSON(w, http.StatusNotFound, errorResponse{Error: err.Error()})
return
}
Expand Down
9 changes: 5 additions & 4 deletions internal/heartbeat/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log/slog"
"time"

"github.com/containeroo/heartbeats/internal/logging"
"github.com/containeroo/heartbeats/internal/metrics"
"github.com/containeroo/heartbeats/internal/notifier"
servicehistory "github.com/containeroo/heartbeats/internal/service/history"
Expand Down Expand Up @@ -142,7 +143,7 @@ func (a *Actor) onReceive() {

a.State = HeartbeatStateActive
if err := a.recordStateChange(prev, a.State); err != nil {
a.logger.Error("failed to record state change", "err", err)
logging.BusinessLogger(a.logger, a.ctx).Error("failed to record state change", "err", err)
}
a.LastBump = now
a.checkTimer = time.NewTimer(a.Interval)
Expand Down Expand Up @@ -171,7 +172,7 @@ func (a *Actor) onFail() {

a.State = HeartbeatStateFailed
if err := a.recordStateChange(prev, a.State); err != nil {
a.logger.Error("failed to record state change", "err", err)
logging.BusinessLogger(a.logger, a.ctx).Error("failed to record state change", "err", err)
}

a.metrics.SetHeartbeatStatus(a.ID, metrics.DOWN)
Expand All @@ -186,7 +187,7 @@ func (a *Actor) onEnterGrace() {
prev := a.State
a.State = HeartbeatStateGrace
if err := a.recordStateChange(prev, a.State); err != nil {
a.logger.Error("failed to record state change", "err", err)
logging.BusinessLogger(a.logger, a.ctx).Error("failed to record state change", "err", err)
}

a.graceTimer = time.NewTimer(a.Grace)
Expand All @@ -200,7 +201,7 @@ func (a *Actor) onEnterMissing() {
prev := a.State
a.State = HeartbeatStateMissing
if err := a.recordStateChange(prev, a.State); err != nil {
a.logger.Error("failed to record state change", "err", err)
logging.BusinessLogger(a.logger, a.ctx).Error("failed to record state change", "err", err)
}

// send notification
Expand Down
3 changes: 2 additions & 1 deletion internal/heartbeat/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"slices"
"sync"

"github.com/containeroo/heartbeats/internal/logging"
"github.com/containeroo/heartbeats/internal/utils"
)

Expand Down Expand Up @@ -71,7 +72,7 @@ func (m *Manager) StartAll() int {
ma.started = true
go ma.actor.Run(ctx)
started++
m.logger.Debug("started heartbeat", "id", ma.actor.ID)
logging.SystemLogger(m.logger, nil).Debug("started heartbeat", "id", ma.actor.ID)
}
if started > 0 {
m.started = true
Expand Down
3 changes: 2 additions & 1 deletion internal/heartbeat/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package heartbeat
import (
"time"

"github.com/containeroo/heartbeats/internal/logging"
servicehistory "github.com/containeroo/heartbeats/internal/service/history"
)

Expand Down Expand Up @@ -48,7 +49,7 @@ func (a *Actor) recordStateChange(prev, next HeartbeatState) error {
from := prev.String()
to := next.String()

a.logger.Info("state change",
logging.BusinessLogger(a.logger, a.ctx).Info("state change",
"heartbeat", a.ID,
"from", from,
"to", to,
Expand Down
44 changes: 44 additions & 0 deletions internal/logging/categories.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package logging

import (
"context"
"log/slog"
)

const (
CategoryAccess = "access"
CategoryBusiness = "business"
CategoryDB = "db"
CategorySystem = "system"
)

// WithCategory adds a category field to the logger.
func WithCategory(logger *slog.Logger, category string) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
if category == "" {
return logger
}
return logger.With("category", category)
}

// AccessLogger returns a logger tagged for access logs.
func AccessLogger(logger *slog.Logger, ctx context.Context) *slog.Logger {
return WithCategory(WithRequestIDLogger(logger, ctx), CategoryAccess)
}

// BusinessLogger returns a logger tagged for business logs.
func BusinessLogger(logger *slog.Logger, ctx context.Context) *slog.Logger {
return WithCategory(WithRequestIDLogger(logger, ctx), CategoryBusiness)
}

// DBLogger returns a logger tagged for db logs.
func DBLogger(logger *slog.Logger, ctx context.Context) *slog.Logger {
return WithCategory(WithRequestIDLogger(logger, ctx), CategoryDB)
}

// SystemLogger returns a logger tagged for system logs.
func SystemLogger(logger *slog.Logger, ctx context.Context) *slog.Logger {
return WithCategory(WithRequestIDLogger(logger, ctx), CategorySystem)
}
1 change: 1 addition & 0 deletions internal/logging/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
)

// TestSetupLogger validates logger output formats and log levels.
func TestSetupLogger(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading