Skip to content

Commit a735378

Browse files
committed
feat(telegram): add health HTTP endpoint for monitoring
Adds HealthServer serving /health with JSON status and uptime. Returns 200 {'status':'ok','uptime_seconds':N} when ready, 503 when starting. Configure via health_addr in config or ODEK_TELEGRAM_HEALTH_ADDR env var. Disabled by default (empty).
1 parent 8c15ba5 commit a735378

5 files changed

Lines changed: 223 additions & 0 deletions

File tree

cmd/odek/telegram.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,19 @@ func telegramCmd(args []string) error {
473473
ctx, cancel := context.WithCancel(context.Background())
474474
defer cancel()
475475

476+
// 14b. Start health server if configured.
477+
if cfg.HealthAddr != "" {
478+
healthSrv := telegram.NewHealthServer(cfg.HealthAddr)
479+
healthSrv.SetLogger(rootLog.With("component", "health"))
480+
go func() {
481+
if err := healthSrv.Start(ctx); err != nil {
482+
fmt.Fprintf(os.Stderr, "odek telegram: health server: %v\n", err)
483+
}
484+
}()
485+
// Mark ready once polling begins.
486+
defer healthSrv.SetReady()
487+
}
488+
476489
// 15. Handle SIGINT/SIGTERM/SIGHUP.
477490
// SIGHUP spawns a child process then exits (used by /restart and
478491
// agent-triggered restarts). The child's acquireLock kills this

internal/telegram/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type TelegramConfig struct {
1919
DailyTokenBudget int64 `json:"daily_token_budget"` // 0 = unlimited (default)
2020
SessionTTL int `json:"session_ttl_hours"` // hours, default 24
2121
FallbackURLs []string `json:"fallback_urls"`
22+
HealthAddr string `json:"health_addr"` // e.g. "127.0.0.1:9090" (empty = disabled)
2223
LogLevel string `json:"log_level"` // "debug","info","warn","error" (default "info")
2324
LogFile string `json:"log_file"` // path or empty for stderr
2425
}
@@ -79,6 +80,9 @@ func ConfigFromEnv(base TelegramConfig) TelegramConfig {
7980
if v := os.Getenv("ODEK_TELEGRAM_FALLBACK_URLS"); v != "" {
8081
cfg.FallbackURLs = splitAndTrim(v)
8182
}
83+
if v := os.Getenv("ODEK_TELEGRAM_HEALTH_ADDR"); v != "" {
84+
cfg.HealthAddr = v
85+
}
8286
if v := os.Getenv("ODEK_TELEGRAM_LOG_LEVEL"); v != "" {
8387
cfg.LogLevel = v
8488
}

internal/telegram/config_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ func TestDefaultConfig(t *testing.T) {
4242
if cfg.FallbackURLs != nil {
4343
t.Errorf("DefaultConfig().FallbackURLs = %v, want nil", cfg.FallbackURLs)
4444
}
45+
if cfg.HealthAddr != "" {
46+
t.Errorf("DefaultConfig().HealthAddr = %q, want empty string", cfg.HealthAddr)
47+
}
4548
}
4649

4750
// ---------------------------------------------------------------------------
@@ -284,6 +287,14 @@ func TestConfigFromEnv_fallbackURLsTrimsEmptyEntries(t *testing.T) {
284287
}
285288
}
286289

290+
func TestConfigFromEnv_healthAddr(t *testing.T) {
291+
t.Setenv("ODEK_TELEGRAM_HEALTH_ADDR", "127.0.0.1:9090")
292+
cfg := ConfigFromEnv(DefaultConfig())
293+
if cfg.HealthAddr != "127.0.0.1:9090" {
294+
t.Errorf("HealthAddr = %q, want 127.0.0.1:9090", cfg.HealthAddr)
295+
}
296+
}
297+
287298
func TestConfigFromEnv_multipleOverrides(t *testing.T) {
288299
// Multiple env vars set at once.
289300
t.Setenv("ODEK_TELEGRAM_BOT_TOKEN", "token:multi")

internal/telegram/health.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package telegram
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"os"
10+
"time"
11+
)
12+
13+
// HealthServer serves a lightweight HTTP health check endpoint.
14+
// It reports bot liveness and uptime for monitoring systems.
15+
type HealthServer struct {
16+
addr string
17+
startTime time.Time
18+
ready bool
19+
log Logger
20+
}
21+
22+
// NewHealthServer creates a HealthServer listening on the given address.
23+
// Use "127.0.0.1:9090" or "0.0.0.0:9090". Empty string disables the server.
24+
func NewHealthServer(addr string) *HealthServer {
25+
return &HealthServer{
26+
addr: addr,
27+
startTime: time.Now(),
28+
log: NewNopLogger(),
29+
}
30+
}
31+
32+
// SetLogger sets the logger. If nil, a NopLogger is used.
33+
func (hs *HealthServer) SetLogger(l Logger) {
34+
if l == nil {
35+
hs.log = NewNopLogger()
36+
return
37+
}
38+
hs.log = l
39+
}
40+
41+
// SetReady marks the health server as ready (polling has started).
42+
func (hs *HealthServer) SetReady() {
43+
hs.ready = true
44+
}
45+
46+
// ServeHTTP implements http.Handler.
47+
func (hs *HealthServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
48+
if r.URL.Path != "/health" {
49+
w.WriteHeader(http.StatusNotFound)
50+
return
51+
}
52+
53+
w.Header().Set("Content-Type", "application/json")
54+
55+
if !hs.ready {
56+
w.WriteHeader(http.StatusServiceUnavailable)
57+
json.NewEncoder(w).Encode(map[string]any{
58+
"status": "starting",
59+
"message": "bot is initializing, polling not yet started",
60+
})
61+
return
62+
}
63+
64+
uptime := time.Since(hs.startTime).Truncate(time.Second)
65+
w.WriteHeader(http.StatusOK)
66+
json.NewEncoder(w).Encode(map[string]any{
67+
"status": "ok",
68+
"uptime_seconds": int(uptime.Seconds()),
69+
})
70+
}
71+
72+
// Start begins listening on the configured address. Blocks until ctx is
73+
// cancelled, then shuts down the HTTP server gracefully.
74+
// Returns any error from starting the listener.
75+
func (hs *HealthServer) Start(ctx context.Context) error {
76+
if hs.addr == "" {
77+
return nil // disabled
78+
}
79+
80+
ln, err := net.Listen("tcp", hs.addr)
81+
if err != nil {
82+
return fmt.Errorf("health server: listen %s: %w", hs.addr, err)
83+
}
84+
85+
srv := &http.Server{Addr: hs.addr, Handler: hs}
86+
hs.log.Info("health server started", "addr", ln.Addr().String())
87+
88+
go func() {
89+
<-ctx.Done()
90+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
91+
defer cancel()
92+
if err := srv.Shutdown(shutdownCtx); err != nil {
93+
fmt.Fprintf(os.Stderr, "health server: shutdown: %v\n", err)
94+
}
95+
}()
96+
97+
if err := srv.Serve(ln); err != http.ErrServerClosed {
98+
return fmt.Errorf("health server: serve: %w", err)
99+
}
100+
return nil
101+
}

internal/telegram/health_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package telegram
2+
3+
import (
4+
"encoding/json"
5+
"net"
6+
"net/http"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestHealthServer_Returns200(t *testing.T) {
12+
hs := NewHealthServer("127.0.0.1:0")
13+
hs.ready = true
14+
15+
ts := &http.Server{Addr: hs.addr, Handler: hs}
16+
ln, err := net.Listen("tcp", "127.0.0.1:0")
17+
if err != nil {
18+
t.Fatalf("listen: %v", err)
19+
}
20+
defer ln.Close()
21+
go ts.Serve(ln)
22+
23+
time.Sleep(50 * time.Millisecond)
24+
25+
resp, err := http.Get("http://" + ln.Addr().String() + "/health")
26+
if err != nil {
27+
t.Fatalf("GET /health: %v", err)
28+
}
29+
defer resp.Body.Close()
30+
31+
if resp.StatusCode != 200 {
32+
t.Errorf("status = %d, want 200", resp.StatusCode)
33+
}
34+
35+
var body map[string]any
36+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
37+
t.Fatalf("decode response: %v", err)
38+
}
39+
if body["status"] != "ok" {
40+
t.Errorf("status = %q, want ok", body["status"])
41+
}
42+
if _, ok := body["uptime_seconds"]; !ok {
43+
t.Error("missing uptime_seconds")
44+
}
45+
}
46+
47+
func TestHealthServer_NotReady(t *testing.T) {
48+
hs := NewHealthServer("127.0.0.1:0")
49+
// ready defaults to false
50+
51+
ts := &http.Server{Addr: hs.addr, Handler: hs}
52+
ln, err := net.Listen("tcp", "127.0.0.1:0")
53+
if err != nil {
54+
t.Fatalf("listen: %v", err)
55+
}
56+
defer ln.Close()
57+
go ts.Serve(ln)
58+
59+
time.Sleep(50 * time.Millisecond)
60+
61+
resp, err := http.Get("http://" + ln.Addr().String() + "/health")
62+
if err != nil {
63+
t.Fatalf("GET /health: %v", err)
64+
}
65+
defer resp.Body.Close()
66+
67+
if resp.StatusCode != 503 {
68+
t.Errorf("status = %d, want 503", resp.StatusCode)
69+
}
70+
}
71+
72+
func TestHealthServer_404OnOtherPaths(t *testing.T) {
73+
hs := NewHealthServer("127.0.0.1:0")
74+
75+
ts := &http.Server{Addr: hs.addr, Handler: hs}
76+
ln, err := net.Listen("tcp", "127.0.0.1:0")
77+
if err != nil {
78+
t.Fatalf("listen: %v", err)
79+
}
80+
defer ln.Close()
81+
go ts.Serve(ln)
82+
83+
time.Sleep(50 * time.Millisecond)
84+
85+
resp, err := http.Get("http://" + ln.Addr().String() + "/metrics")
86+
if err != nil {
87+
t.Fatalf("GET /metrics: %v", err)
88+
}
89+
defer resp.Body.Close()
90+
91+
if resp.StatusCode != 404 {
92+
t.Errorf("status = %d, want 404", resp.StatusCode)
93+
}
94+
}

0 commit comments

Comments
 (0)