Files
chat/internal/healthcheck/healthcheck.go
clawbot 052674b4ee
Some checks failed
check / check (push) Has been cancelled
feat: add runtime statistics to healthcheck endpoint (#80)
## Summary

Expands the `/.well-known/healthcheck.json` endpoint with runtime statistics, giving operators visibility into server load and usage patterns.

closes #74

## New healthcheck fields

| Field | Source | Description |
|-------|--------|-------------|
| `sessions` | DB | Current active session count |
| `clients` | DB | Current connected client count |
| `queuedLines` | DB | Total entries in client output queues |
| `channels` | DB | Current channel count |
| `connectionsSinceBoot` | Memory | Total client connections since server start |
| `sessionsSinceBoot` | Memory | Total sessions created since server start |
| `messagesSinceBoot` | Memory | Total PRIVMSG/NOTICE messages since server start |

## Implementation

- **New `internal/stats` package** — atomic counters for boot-scoped metrics (`connectionsSinceBoot`, `sessionsSinceBoot`, `messagesSinceBoot`). Thread-safe via `sync/atomic`.
- **New DB queries** — `GetClientCount()` and `GetQueueEntryCount()` for current snapshot counts.
- **Healthcheck changes** — `Healthcheck()` now accepts `context.Context` to query the database. Response struct extended with all 7 new fields. DB-derived stats populated with graceful error handling (logged, not fatal).
- **Counter instrumentation** — Increments added at:
  - `handleCreateSession` → `IncrSessions` + `IncrConnections`
  - `handleRegister` → `IncrSessions` + `IncrConnections`
  - `handleLogin` → `IncrConnections` (new client for existing session)
  - `handlePrivmsg` → `IncrMessages` (covers both PRIVMSG and NOTICE)
- **Wired via fx** — `stats.Tracker` provided through Uber fx DI in both production and test setups.

## Tests

- `internal/stats/stats_test.go` — 5 tests covering all counter operations (100% coverage)
- `TestHealthcheckRuntimeStatsFields` — verifies all 7 new fields are present in the response
- `TestHealthcheckRuntimeStatsValues` — end-to-end: creates a session, joins a channel, sends a message, then verifies counts are nonzero

## README

Updated healthcheck documentation with full response shape, field descriptions, and project structure listing for `internal/stats/`.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #80
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-17 12:43:39 +01:00

156 lines
3.8 KiB
Go

// Package healthcheck provides health status reporting for the server.
package healthcheck
import (
"context"
"log/slog"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
// Params defines the dependencies for creating a Healthcheck.
type Params struct {
fx.In
Globals *globals.Globals
Config *config.Config
Logger *logger.Logger
Database *db.Database
Stats *stats.Tracker
}
// Healthcheck tracks server uptime and provides health status.
type Healthcheck struct {
// StartupTime records when the server started.
StartupTime time.Time
log *slog.Logger
params *Params
}
// New creates a new Healthcheck instance.
func New(
lifecycle fx.Lifecycle, params Params,
) (*Healthcheck, error) {
hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart
params: &params,
log: params.Logger.Get(),
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
hcheck.StartupTime = time.Now()
return nil
},
OnStop: func(_ context.Context) error {
return nil
},
})
return hcheck, nil
}
// Response is the JSON response returned by the health endpoint.
type Response struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptimeSeconds"`
UptimeHuman string `json:"uptimeHuman"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"`
// Runtime statistics.
Sessions int64 `json:"sessions"`
Clients int64 `json:"clients"`
QueuedLines int64 `json:"queuedLines"`
Channels int64 `json:"channels"`
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
}
// Healthcheck returns the current health status of the server.
func (hcheck *Healthcheck) Healthcheck(
ctx context.Context,
) *Response {
resp := &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()),
UptimeHuman: hcheck.uptime().String(),
Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode,
Sessions: 0,
Clients: 0,
QueuedLines: 0,
Channels: 0,
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
}
hcheck.populateDBStats(ctx, resp)
return resp
}
// populateDBStats fills in database-derived counters.
func (hcheck *Healthcheck) populateDBStats(
ctx context.Context,
resp *Response,
) {
sessions, err := hcheck.params.Database.GetUserCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: session count failed",
"error", err,
)
} else {
resp.Sessions = sessions
}
clients, err := hcheck.params.Database.GetClientCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: client count failed",
"error", err,
)
} else {
resp.Clients = clients
}
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: queue entry count failed",
"error", err,
)
} else {
resp.QueuedLines = queued
}
channels, err := hcheck.params.Database.GetChannelCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: channel count failed",
"error", err,
)
} else {
resp.Channels = channels
}
}
func (hcheck *Healthcheck) uptime() time.Duration {
return time.Since(hcheck.StartupTime)
}