feat: add runtime statistics to healthcheck endpoint
All checks were successful
check / check (push) Successful in 1m4s

Add the following counters to the healthcheck JSON response:

- sessions: current active session count (from DB)
- clients: current connected client count (from DB)
- queuedLines: total entries in client output queues (from DB)
- channels: current channel count (from DB)
- connectionsSinceBoot: total client connections since server start
- sessionsSinceBoot: total sessions created since server start
- messagesSinceBoot: total PRIVMSG/NOTICE messages since server start

Implementation:
- New internal/stats package with atomic counters for boot-scoped metrics
- New DB queries GetClientCount and GetQueueEntryCount
- Healthcheck.Healthcheck() now accepts context for DB queries
- Counter increments in session creation, registration, login, and messaging
- Stats tracker wired via Uber fx dependency injection
- Unit tests for stats package (100% coverage) and integration tests
- README updated with full healthcheck response documentation

closes #74
This commit is contained in:
user
2026-03-17 02:44:07 -07:00
parent cab5784913
commit 1099fc372f
11 changed files with 462 additions and 5 deletions

View File

@@ -212,6 +212,9 @@ func (hdlr *Handlers) handleCreateSession(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
@@ -977,6 +980,8 @@ func (hdlr *Handlers) handlePrivmsg(
return
}
hdlr.stats.IncrMessages()
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request,

View File

@@ -26,6 +26,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -90,6 +91,7 @@ func newTestServer(
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
@@ -144,12 +146,14 @@ func newTestHealthcheck(
cfg *config.Config,
log *logger.Logger,
database *db.Database,
tracker *stats.Tracker,
) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs,
Config: cfg,
Logger: log,
Database: database,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err)
@@ -183,6 +187,7 @@ func newTestHandlers(
cfg *config.Config,
database *db.Database,
hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) {
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
@@ -190,6 +195,7 @@ func newTestHandlers(
Config: cfg,
Database: database,
Healthcheck: hcheck,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
@@ -1657,6 +1663,133 @@ func TestHealthcheck(t *testing.T) {
}
}
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
requiredFields := []string{
"sessions", "clients", "queuedLines",
"channels", "connectionsSinceBoot",
"sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
)
}
}
}
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("statsuser")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
})
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#statschan",
bodyKey: []string{"hello stats"},
})
result := tserver.fetchHealthcheck(t)
assertFieldGTE(t, result, "sessions", 1)
assertFieldGTE(t, result, "clients", 1)
assertFieldGTE(t, result, "channels", 1)
assertFieldGTE(t, result, "queuedLines", 0)
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
assertFieldGTE(t, result, "messagesSinceBoot", 1)
}
func (tserver *testServer) fetchHealthcheck(
t *testing.T,
) map[string]any {
t.Helper()
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
return result
}
func assertFieldGTE(
t *testing.T,
result map[string]any,
field string,
minimum float64,
) {
t.Helper()
val, ok := result[field].(float64)
if !ok {
t.Errorf(
"field %q: not a number (got %T)",
field, result[field],
)
return
}
if val < minimum {
t.Errorf(
"expected %s >= %v, got %v",
field, minimum, val,
)
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)

View File

@@ -82,6 +82,9 @@ func (hdlr *Handlers) handleRegister(
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
@@ -180,6 +183,8 @@ func (hdlr *Handlers) handleLogin(
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)

View File

@@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -30,6 +31,7 @@ type Params struct {
Config *config.Config
Database *db.Database
Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
}
const defaultIdleTimeout = 30 * 24 * time.Hour
@@ -41,6 +43,7 @@ type Handlers struct {
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
@@ -60,6 +63,7 @@ func New(
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{

View File

@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
writer http.ResponseWriter,
request *http.Request,
) {
resp := hdlr.hc.Healthcheck()
resp := hdlr.hc.Healthcheck(request.Context())
hdlr.respondJSON(writer, request, resp, httpStatusOK)
}
}