278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"chat/src/auth"
|
|
"chat/src/db"
|
|
"chat/src/ws"
|
|
)
|
|
|
|
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
|
|
|
|
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
|
|
|
type requestDurationHistogram struct {
|
|
mu sync.Mutex
|
|
buckets []float64
|
|
counts []uint64
|
|
sum float64
|
|
total uint64
|
|
}
|
|
|
|
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
|
|
return &requestDurationHistogram{
|
|
buckets: append([]float64(nil), buckets...),
|
|
counts: make([]uint64, len(buckets)+1),
|
|
}
|
|
}
|
|
|
|
func (h *requestDurationHistogram) observe(durationSeconds float64) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
h.total++
|
|
h.sum += durationSeconds
|
|
for index, bucket := range h.buckets {
|
|
if durationSeconds <= bucket {
|
|
h.counts[index]++
|
|
return
|
|
}
|
|
}
|
|
h.counts[len(h.counts)-1]++
|
|
}
|
|
|
|
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
counts = append([]uint64(nil), h.counts...)
|
|
return counts, h.sum, h.total
|
|
}
|
|
|
|
// ObserveRequestDuration records a chat HTTP request duration in seconds.
|
|
func ObserveRequestDuration(duration time.Duration) {
|
|
chatRequestDurationHistogram.observe(duration.Seconds())
|
|
}
|
|
|
|
// WrapRequestTiming records request duration for the provided handler.
|
|
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
startedAt := time.Now()
|
|
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
|
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var storedMessages int64
|
|
if gormDB != nil {
|
|
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
|
}
|
|
|
|
var moderationActions int64
|
|
if gormDB != nil {
|
|
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
|
}
|
|
|
|
redisEnabled := 0
|
|
redisUp := 0
|
|
redisClients := 0
|
|
redisMemoryUsedBytes := int64(0)
|
|
redisMemoryMaxBytes := int64(0)
|
|
redisMemoryUsageRatio := 0.0
|
|
if redis != nil {
|
|
redisEnabled = 1
|
|
if err := redis.Ping(context.Background()); err == nil {
|
|
redisUp = 1
|
|
if info, err := redis.Info(context.Background(), "clients"); err == nil {
|
|
for _, line := range strings.Split(info, "\n") {
|
|
if strings.HasPrefix(line, "connected_clients:") {
|
|
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
|
|
redisClients = value
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if info, err := redis.Info(context.Background(), "memory"); err == nil {
|
|
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
|
|
redisMemoryUsedBytes = value
|
|
}
|
|
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
|
|
redisMemoryMaxBytes = value
|
|
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
|
|
redisMemoryMaxBytes = value
|
|
}
|
|
if redisMemoryMaxBytes > 0 {
|
|
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
connectedClients := 0
|
|
dmsSent := int64(0)
|
|
if hub != nil {
|
|
connectedClients = hub.ConnectedClients()
|
|
dmsSent = hub.DMsSent
|
|
}
|
|
|
|
activeSessions := 0
|
|
if sessionStore != nil {
|
|
activeSessions = sessionStore.CountActive()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
|
|
writeLine := func(name string, value any) {
|
|
fmt.Fprintf(w, "%s %v\n", name, value)
|
|
}
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
|
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
|
writeLine("linearvoid_chat_messages_total", storedMessages)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
|
writeLine("linearvoid_chat_dms_total", dmsSent)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
|
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
|
|
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
|
|
writeLine("linearvoid_chat_redis_up", redisUp)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
|
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
|
|
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
|
|
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
|
|
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
|
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
|
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
|
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
|
|
|
counts, sum, total := chatRequestDurationHistogram.snapshot()
|
|
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
|
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
|
|
cumulative := uint64(0)
|
|
for index, bucket := range requestDurationBuckets {
|
|
cumulative += counts[index]
|
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
|
|
}
|
|
cumulative += counts[len(counts)-1]
|
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
|
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
|
|
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
|
|
}
|
|
}
|
|
|
|
// HandleHealth returns a simple health check endpoint.
|
|
func HandleHealth() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
|
}
|
|
}
|
|
|
|
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
|
func HandleNotFound() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprint(w, `{"error":"not found"}`)
|
|
}
|
|
}
|
|
|
|
func parseRedisMemoryBytes(info, key string) int64 {
|
|
for _, line := range strings.Split(info, "\n") {
|
|
if !strings.HasPrefix(line, key+":") {
|
|
continue
|
|
}
|
|
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
return raw
|
|
}
|
|
return parseHumanBytes(value)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseHumanBytes(value string) int64 {
|
|
value = strings.TrimSpace(strings.ToUpper(value))
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
|
|
multiplier := float64(1)
|
|
suffixes := map[string]float64{
|
|
"B": 1,
|
|
"K": 1024,
|
|
"KB": 1024,
|
|
"M": 1024 * 1024,
|
|
"MB": 1024 * 1024,
|
|
"G": 1024 * 1024 * 1024,
|
|
"GB": 1024 * 1024 * 1024,
|
|
"T": 1024 * 1024 * 1024 * 1024,
|
|
"TB": 1024 * 1024 * 1024 * 1024,
|
|
}
|
|
|
|
for suffix, factor := range suffixes {
|
|
if strings.HasSuffix(value, suffix) {
|
|
multiplier = factor
|
|
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
|
break
|
|
}
|
|
}
|
|
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int64(parsed * multiplier)
|
|
} |