diff --git a/README.md b/README.md index 2f48e23..ed5bb5f 100644 --- a/README.md +++ b/README.md @@ -1399,13 +1399,40 @@ Return server metadata. No authentication required. ### GET /.well-known/healthcheck.json — Health Check -Standard health check endpoint. No authentication required. +Standard health check endpoint. No authentication required. Returns server +health status and runtime statistics. **Response:** `200 OK` + ```json -{"status": "ok"} +{ + "status": "ok", + "now": "2024-01-15T12:00:00.000000000Z", + "uptimeSeconds": 3600, + "uptimeHuman": "1h0m0s", + "version": "0.1.0", + "appname": "neoirc", + "maintenanceMode": false, + "sessions": 42, + "clients": 85, + "queuedLines": 128, + "channels": 7, + "connectionsSinceBoot": 200, + "sessionsSinceBoot": 150, + "messagesSinceBoot": 5000 +} ``` +| Field | Description | +| ---------------------- | ------------------------------------------------- | +| `sessions` | Current number of active sessions | +| `clients` | Current number of connected clients | +| `queuedLines` | Total entries in client output queues | +| `channels` | Current number of channels | +| `connectionsSinceBoot` | Total client connections since server start | +| `sessionsSinceBoot` | Total sessions created since server start | +| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start | + --- ## Message Flow @@ -2332,6 +2359,8 @@ neoirc/ │ │ └── healthcheck.go # Health check handler │ ├── healthcheck/ # Health check logic │ │ └── healthcheck.go +│ ├── stats/ # Runtime statistics (atomic counters) +│ │ └── stats.go │ ├── logger/ # slog-based logging │ │ └── logger.go │ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) diff --git a/cmd/neoircd/main.go b/cmd/neoircd/main.go index e7b0445..d8d6601 100644 --- a/cmd/neoircd/main.go +++ b/cmd/neoircd/main.go @@ -10,6 +10,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" ) @@ -35,6 +36,7 @@ func main() { server.New, middleware.New, healthcheck.New, + stats.New, ), fx.Invoke(func(*server.Server) {}), ).Run() diff --git a/internal/db/queries.go b/internal/db/queries.go index e97b1f0..ff13174 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1266,3 +1266,42 @@ func (database *Database) PruneOldMessages( return deleted, nil } + +// GetClientCount returns the total number of clients. +func (database *Database) GetClientCount( + ctx context.Context, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM clients", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "get client count: %w", err, + ) + } + + return count, nil +} + +// GetQueueEntryCount returns the total number of entries +// in the client output queues. +func (database *Database) GetQueueEntryCount( + ctx context.Context, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM client_queues", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "get queue entry count: %w", err, + ) + } + + return count, nil +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 74f9f9a..6be632f 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index e4da9cb..08755d0 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index cd99bf7..293636f 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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, ) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6a76ae3..a1e4f34 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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{ diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go index 99f0af2..9d66ea2 100644 --- a/internal/handlers/healthcheck.go +++ b/internal/handlers/healthcheck.go @@ -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) } } diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go index 4f4f2ac..0df9867 100644 --- a/internal/healthcheck/healthcheck.go +++ b/internal/healthcheck/healthcheck.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -21,6 +22,7 @@ type Params struct { Config *config.Config Logger *logger.Logger Database *db.Database + Stats *stats.Tracker } // Healthcheck tracks server uptime and provides health status. @@ -64,11 +66,22 @@ type Response struct { 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() *Response { - return &Response{ +func (hcheck *Healthcheck) Healthcheck( + ctx context.Context, +) *Response { + resp := &Response{ Status: "ok", Now: time.Now().UTC().Format(time.RFC3339Nano), UptimeSeconds: int64(hcheck.uptime().Seconds()), @@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response { 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 } } diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..462435b --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,52 @@ +// Package stats tracks runtime statistics since server boot. +package stats + +import ( + "sync/atomic" +) + +// Tracker holds atomic counters for runtime statistics +// that accumulate since the server started. +type Tracker struct { + connectionsSinceBoot atomic.Int64 + sessionsSinceBoot atomic.Int64 + messagesSinceBoot atomic.Int64 +} + +// New creates a new Tracker with all counters at zero. +func New() *Tracker { + return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults +} + +// IncrConnections increments the total connection count. +func (t *Tracker) IncrConnections() { + t.connectionsSinceBoot.Add(1) +} + +// IncrSessions increments the total session count. +func (t *Tracker) IncrSessions() { + t.sessionsSinceBoot.Add(1) +} + +// IncrMessages increments the total PRIVMSG/NOTICE count. +func (t *Tracker) IncrMessages() { + t.messagesSinceBoot.Add(1) +} + +// ConnectionsSinceBoot returns the total number of +// client connections since boot. +func (t *Tracker) ConnectionsSinceBoot() int64 { + return t.connectionsSinceBoot.Load() +} + +// SessionsSinceBoot returns the total number of sessions +// created since boot. +func (t *Tracker) SessionsSinceBoot() int64 { + return t.sessionsSinceBoot.Load() +} + +// MessagesSinceBoot returns the total number of +// PRIVMSG/NOTICE messages sent since boot. +func (t *Tracker) MessagesSinceBoot() int64 { + return t.messagesSinceBoot.Load() +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 0000000..ce53620 --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,117 @@ +package stats_test + +import ( + "testing" + + "git.eeqj.de/sneak/neoirc/internal/stats" +) + +func TestNew(t *testing.T) { + t.Parallel() + + tracker := stats.New() + if tracker == nil { + t.Fatal("expected non-nil tracker") + } + + if tracker.ConnectionsSinceBoot() != 0 { + t.Errorf( + "expected 0 connections, got %d", + tracker.ConnectionsSinceBoot(), + ) + } + + if tracker.SessionsSinceBoot() != 0 { + t.Errorf( + "expected 0 sessions, got %d", + tracker.SessionsSinceBoot(), + ) + } + + if tracker.MessagesSinceBoot() != 0 { + t.Errorf( + "expected 0 messages, got %d", + tracker.MessagesSinceBoot(), + ) + } +} + +func TestIncrConnections(t *testing.T) { + t.Parallel() + + tracker := stats.New() + + tracker.IncrConnections() + tracker.IncrConnections() + tracker.IncrConnections() + + got := tracker.ConnectionsSinceBoot() + if got != 3 { + t.Errorf( + "expected 3 connections, got %d", got, + ) + } +} + +func TestIncrSessions(t *testing.T) { + t.Parallel() + + tracker := stats.New() + + tracker.IncrSessions() + tracker.IncrSessions() + + got := tracker.SessionsSinceBoot() + if got != 2 { + t.Errorf( + "expected 2 sessions, got %d", got, + ) + } +} + +func TestIncrMessages(t *testing.T) { + t.Parallel() + + tracker := stats.New() + + tracker.IncrMessages() + + got := tracker.MessagesSinceBoot() + if got != 1 { + t.Errorf( + "expected 1 message, got %d", got, + ) + } +} + +func TestCountersAreIndependent(t *testing.T) { + t.Parallel() + + tracker := stats.New() + + tracker.IncrConnections() + tracker.IncrSessions() + tracker.IncrMessages() + tracker.IncrMessages() + + if tracker.ConnectionsSinceBoot() != 1 { + t.Errorf( + "expected 1 connection, got %d", + tracker.ConnectionsSinceBoot(), + ) + } + + if tracker.SessionsSinceBoot() != 1 { + t.Errorf( + "expected 1 session, got %d", + tracker.SessionsSinceBoot(), + ) + } + + if tracker.MessagesSinceBoot() != 2 { + t.Errorf( + "expected 2 messages, got %d", + tracker.MessagesSinceBoot(), + ) + } +}