Changeset 0.33.0 (#207)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"chat-switchboard/filters"
|
||||
"chat-switchboard/health"
|
||||
"chat-switchboard/knowledge"
|
||||
"chat-switchboard/metrics"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/notifications"
|
||||
"chat-switchboard/providers"
|
||||
@@ -224,6 +226,16 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
// v0.33.0: Correlation ID — same as request_id, propagated through
|
||||
// completion chain for cross-referencing logs.
|
||||
correlationID, _ := c.Get("request_id")
|
||||
slog.Info("completion.start",
|
||||
"request_id", correlationID,
|
||||
"channel_id", channelID,
|
||||
"user_id", userID,
|
||||
"model", req.Model,
|
||||
)
|
||||
|
||||
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
|
||||
if isSessionAuth(c) {
|
||||
if !sessionCanAccessChannel(c, channelID) {
|
||||
@@ -1912,6 +1924,20 @@ func (h *CompletionHandler) logUsage(
|
||||
if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil {
|
||||
log.Printf("⚠ Failed to log usage: %v", err)
|
||||
}
|
||||
|
||||
// v0.33.0: Prometheus token counters
|
||||
metrics.CompletionTokensTotal.WithLabelValues("prompt", modelID).Add(float64(inputTokens))
|
||||
metrics.CompletionTokensTotal.WithLabelValues("completion", modelID).Add(float64(outputTokens))
|
||||
|
||||
// v0.33.0: Structured usage log for observability correlation
|
||||
correlationID, _ := c.Get("request_id")
|
||||
slog.Info("completion.usage",
|
||||
"request_id", correlationID,
|
||||
"model_id", modelID,
|
||||
"provider_config_id", configID,
|
||||
"input_tokens", inputTokens,
|
||||
"output_tokens", outputTokens,
|
||||
)
|
||||
}
|
||||
|
||||
// calcCost computes the cost for a given token count and price-per-million.
|
||||
@@ -1923,23 +1949,36 @@ func calcCost(tokens int, pricePerM *float64) *float64 {
|
||||
return &cost
|
||||
}
|
||||
|
||||
// recordHealth records provider call outcome for health tracking (v0.22.0).
|
||||
// recordHealth records provider call outcome for health tracking (v0.22.0)
|
||||
// and Prometheus completion metrics (v0.33.0).
|
||||
func (h *CompletionHandler) recordHealth(configID string, start time.Time, err error) {
|
||||
duration := time.Since(start)
|
||||
latencyMs := int(duration.Milliseconds())
|
||||
|
||||
// v0.33.0: Prometheus completion duration (always, even on error)
|
||||
if configID != "" {
|
||||
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
if h.health == nil || configID == "" {
|
||||
return
|
||||
}
|
||||
latencyMs := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
status := "error"
|
||||
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
|
||||
h.health.RecordTimeout(configID, latencyMs, errMsg)
|
||||
status = "timeout"
|
||||
} else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
|
||||
h.health.RecordRateLimit(configID, latencyMs, errMsg)
|
||||
status = "rate_limited"
|
||||
} else {
|
||||
h.health.RecordError(configID, latencyMs, errMsg)
|
||||
}
|
||||
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
|
||||
} else {
|
||||
h.health.RecordSuccess(configID, latencyMs)
|
||||
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
server/handlers/dashboard_admin.go
Normal file
118
server/handlers/dashboard_admin.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/database"
|
||||
"chat-switchboard/events"
|
||||
"chat-switchboard/health"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Dashboard Admin Handler ─────────────────
|
||||
// GET /api/v1/admin/dashboard
|
||||
// Aggregates live operational data for the built-in admin monitoring page.
|
||||
|
||||
var processStartTime = time.Now()
|
||||
|
||||
type DashboardAdminHandler struct {
|
||||
stores store.Stores
|
||||
healthStore health.Store
|
||||
hub *events.Hub
|
||||
}
|
||||
|
||||
func NewDashboardAdminHandler(stores store.Stores, hs health.Store, hub *events.Hub) *DashboardAdminHandler {
|
||||
return &DashboardAdminHandler{stores: stores, healthStore: hs, hub: hub}
|
||||
}
|
||||
|
||||
type runtimeStats struct {
|
||||
Goroutines int `json:"goroutines"`
|
||||
HeapMB int `json:"heap_mb"`
|
||||
SysMB int `json:"sys_mb"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
func (h *DashboardAdminHandler) GetDashboard(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Provider health summaries
|
||||
var providerHealth []models.ProviderHealthSummary
|
||||
windows, err := h.healthStore.ListAllCurrent(ctx)
|
||||
if err == nil {
|
||||
for _, w := range windows {
|
||||
providerHealth = append(providerHealth, models.ProviderHealthSummary{
|
||||
ProviderConfigID: w.ProviderConfigID,
|
||||
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
|
||||
RequestCount: w.RequestCount,
|
||||
ErrorRate: w.ErrorRate(),
|
||||
ErrorCount: w.ErrorCount,
|
||||
RateLimitCount: w.RateLimitCount,
|
||||
TimeoutCount: w.TimeoutCount,
|
||||
AvgLatencyMs: w.AvgLatencyMs(),
|
||||
MaxLatencyMs: w.MaxLatencyMs,
|
||||
LastError: w.LastError,
|
||||
LastErrorAt: w.LastErrorAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 24h usage totals
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
totals, _ := h.stores.Usage.GetTotals(ctx, store.UsageQueryOptions{
|
||||
Since: &since,
|
||||
})
|
||||
|
||||
// DB pool stats
|
||||
var dbPool *sql.DBStats
|
||||
if database.DB != nil {
|
||||
stats := database.DB.Stats()
|
||||
dbPool = &stats
|
||||
}
|
||||
|
||||
// WebSocket connections
|
||||
wsCount := 0
|
||||
if h.hub != nil {
|
||||
wsCount = h.hub.ConnCount()
|
||||
}
|
||||
|
||||
// Recent errors from audit log (last 10 error-related entries)
|
||||
var recentErrors []models.AuditEntry
|
||||
entries, _, auditErr := h.stores.Audit.List(ctx, store.AuditListOptions{
|
||||
ListOptions: store.ListOptions{Limit: 10},
|
||||
ResourceType: "error",
|
||||
})
|
||||
if auditErr == nil {
|
||||
recentErrors = entries
|
||||
}
|
||||
|
||||
// Go runtime stats
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
rt := runtimeStats{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
HeapMB: int(mem.HeapAlloc / 1024 / 1024),
|
||||
SysMB: int(mem.Sys / 1024 / 1024),
|
||||
NumGC: mem.NumGC,
|
||||
GoVersion: runtime.Version(),
|
||||
}
|
||||
|
||||
// Uptime
|
||||
uptime := time.Since(processStartTime).Truncate(time.Second).String()
|
||||
|
||||
SafeJSON(c, http.StatusOK, gin.H{
|
||||
"provider_health": providerHealth,
|
||||
"usage_24h": totals,
|
||||
"db_pool": dbPool,
|
||||
"ws_connections": wsCount,
|
||||
"recent_errors": recentErrors,
|
||||
"runtime": rt,
|
||||
"uptime": uptime,
|
||||
})
|
||||
}
|
||||
@@ -12,27 +12,39 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/events"
|
||||
"chat-switchboard/metrics"
|
||||
"chat-switchboard/providers"
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// recordHealthFn records provider health from standalone streaming functions.
|
||||
// recordHealthFn records provider health from standalone streaming functions
|
||||
// and updates Prometheus completion metrics (v0.33.0).
|
||||
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
|
||||
duration := time.Since(start)
|
||||
latencyMs := int(duration.Milliseconds())
|
||||
|
||||
if configID != "" {
|
||||
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
if hr == nil || configID == "" {
|
||||
return
|
||||
}
|
||||
latencyMs := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
status := "error"
|
||||
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
|
||||
hr.RecordRateLimit(configID, latencyMs, errMsg)
|
||||
status = "rate_limited"
|
||||
} else {
|
||||
hr.RecordError(configID, latencyMs, errMsg)
|
||||
}
|
||||
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
|
||||
} else {
|
||||
hr.RecordSuccess(configID, latencyMs)
|
||||
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user