diff --git a/config/prometheus.local.yaml.example b/config/prometheus.local.yaml.example new file mode 100644 index 00000000..8df457f6 --- /dev/null +++ b/config/prometheus.local.yaml.example @@ -0,0 +1,13 @@ +# Copy to config/prometheus.local.yaml for local Prometheus scraping: +# cp config/prometheus.local.yaml.example config/prometheus.local.yaml + +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: olla + metrics_path: /internal/metrics + static_configs: + - targets: + - olla:40114 diff --git a/docker-compose.local.yaml.example b/docker-compose.local.yaml.example new file mode 100644 index 00000000..3d601db9 --- /dev/null +++ b/docker-compose.local.yaml.example @@ -0,0 +1,53 @@ +# Copy to docker-compose.local.yaml for local development: +# cp docker-compose.local.yaml.example docker-compose.local.yaml +# cp config/prometheus.local.yaml.example config/prometheus.local.yaml +# +# Build the local Olla image first: +# make docker-build-local # amd64 +# make docker-build-local-arm64 # Apple Silicon / ARM64 +# +# Start stack: +# docker compose -f docker-compose.yaml -f docker-compose.local.yaml up +# +# Olla: http://localhost:40114 +# Metrics: http://localhost:40114/internal/metrics +# Prometheus: http://localhost:9090 +# Grafana: http://localhost:3000 (admin / admin) + +services: + olla: + image: ghcr.io/thushan/olla:local-${DOCKER_ARCH:-amd64} + + prometheus: + image: prom/prometheus:v3.2.1 + container_name: olla-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./config/prometheus.local.yaml:/etc/prometheus/prometheus.yml:ro + networks: + - ollama-network + depends_on: + olla: + condition: service_healthy + + grafana: + image: grafana/grafana:11.5.2 + container_name: olla-grafana + restart: unless-stopped + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana_data:/var/lib/grafana + networks: + - ollama-network + depends_on: + - prometheus + +volumes: + grafana_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index e1b78fbd..639f901c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,6 +22,11 @@ services: networks: - ollama-network +# Local development with Prometheus/Grafana overlay: +# cp docker-compose.local.yaml.example docker-compose.local.yaml +# cp config/prometheus.local.yaml.example config/prometheus.local.yaml +# make docker-build-local && docker compose -f docker-compose.yaml -f docker-compose.local.yaml up + # Example: Run Ollama alongside Olla # ollama: # image: ollama/ollama:latest diff --git a/internal/app/handlers/handler_metrics.go b/internal/app/handlers/handler_metrics.go new file mode 100644 index 00000000..848da6ae --- /dev/null +++ b/internal/app/handlers/handler_metrics.go @@ -0,0 +1,227 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/core/constants" + "github.com/thushan/olla/internal/core/domain" + "github.com/thushan/olla/internal/core/ports" + "github.com/thushan/olla/internal/version" +) + +func (a *Application) metricsHandler(w http.ResponseWriter, r *http.Request) { + snapshot, err := a.gatherStatusSnapshot(r.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get endpoint data: %v", err), http.StatusInternalServerError) + return + } + + response := a.buildStatusResponse(snapshot) + + w.Header().Set(constants.HeaderContentType, constants.ContentTypePrometheus) + w.WriteHeader(http.StatusOK) + writePrometheusMetrics(w, response, snapshot, a.Config.Proxy, time.Since(a.StartTime)) +} + +func writePrometheusMetrics(w http.ResponseWriter, response StatusResponse, snapshot *statusSnapshot, proxyConfig config.ProxyConfig, uptime time.Duration) { + var b strings.Builder + + writePrometheusHelpType(&b, "olla_info", "gauge", "Olla build and proxy configuration") + writePrometheusLabeledGauge(&b, "olla_info", 1, + "version", version.Version, + "commit", version.Commit, + "engine", proxyConfig.Engine, + "profile", proxyConfig.Profile, + "balancer", proxyConfig.LoadBalancer, + ) + + writePrometheusHelpType(&b, "olla_system_status", "gauge", "Overall system status (2=healthy, 1=degraded, 0=critical)") + writePrometheusGauge(&b, "olla_system_status", systemStatusValue(response.System.Status)) + + writePrometheusHelpType(&b, "olla_endpoints_total", "gauge", "Total configured endpoints") + writePrometheusGauge(&b, "olla_endpoints_total", float64(len(snapshot.all))) + + writePrometheusHelpType(&b, "olla_endpoints_healthy", "gauge", "Number of healthy endpoints") + writePrometheusGauge(&b, "olla_endpoints_healthy", float64(len(snapshot.healthy))) + + writePrometheusHelpType(&b, "olla_success_rate_percent", "gauge", "Proxy success rate percentage") + writePrometheusGauge(&b, "olla_success_rate_percent", parsePercentage(response.System.SuccessRate)) + + writePrometheusHelpType(&b, "olla_avg_latency_ms", "gauge", "Average proxy latency in milliseconds") + writePrometheusGauge(&b, "olla_avg_latency_ms", float64(snapshot.proxyStats.AverageLatency)) + + writePrometheusHelpType(&b, "olla_total_traffic_bytes", "gauge", "Total traffic across all endpoints in bytes") + writePrometheusGauge(&b, "olla_total_traffic_bytes", float64(totalTrafficBytes(snapshot))) + + writePrometheusHelpType(&b, "olla_uptime_seconds", "gauge", "Olla process uptime in seconds") + writePrometheusGauge(&b, "olla_uptime_seconds", uptime.Seconds()) + + writePrometheusHelpType(&b, "olla_active_connections", "gauge", "Active connections across all endpoints") + writePrometheusGauge(&b, "olla_active_connections", float64(response.System.ActiveConnections)) + + writePrometheusHelpType(&b, "olla_security_violations_total", "counter", "Total security violations") + writePrometheusGauge(&b, "olla_security_violations_total", float64(response.System.SecurityViolations)) + + writePrometheusHelpType(&b, "olla_requests_total", "counter", "Total proxy requests processed") + writePrometheusGauge(&b, "olla_requests_total", float64(response.System.TotalRequests)) + + writePrometheusHelpType(&b, "olla_failures_total", "counter", "Total failed proxy requests") + writePrometheusGauge(&b, "olla_failures_total", float64(response.System.TotalFailures)) + + writePrometheusHelpType(&b, "olla_security_blocked_ips", "gauge", "Unique IPs blocked by rate limiting") + writePrometheusGauge(&b, "olla_security_blocked_ips", float64(response.Security.BlockedIPs)) + + writePrometheusHelpType(&b, "olla_security_rate_limit_violations_total", "counter", "Rate limit violations") + writePrometheusGauge(&b, "olla_security_rate_limit_violations_total", float64(response.Security.Violations.RateLimits)) + + writePrometheusHelpType(&b, "olla_security_size_limit_violations_total", "counter", "Request size limit violations") + writePrometheusGauge(&b, "olla_security_size_limit_violations_total", float64(response.Security.Violations.SizeLimits)) + + writePrometheusHelpType(&b, "olla_security_status", "gauge", "Security posture (1=normal, 0=elevated)") + writePrometheusGauge(&b, "olla_security_status", securityStatusValue(response.Security.Status)) + + writeEndpointPrometheusMetrics(&b, snapshot.all, snapshot.endpointStats, snapshot.connectionStats, snapshot.endpointModels) + + _, _ = w.Write([]byte(b.String())) +} + +func writeEndpointPrometheusMetrics(b *strings.Builder, all []*domain.Endpoint, statsMap map[string]ports.EndpointStats, + connectionStats map[string]int64, modelMap map[string]*domain.EndpointModels) { + writePrometheusHelpType(b, "olla_endpoint_up", "gauge", "Whether the endpoint is healthy (1) or not (0)") + writePrometheusHelpType(b, "olla_endpoint_requests_total", "counter", "Total requests handled by endpoint") + writePrometheusHelpType(b, "olla_endpoint_connections", "gauge", "Active connections for endpoint") + writePrometheusHelpType(b, "olla_endpoint_success_rate_percent", "gauge", "Endpoint success rate percentage") + writePrometheusHelpType(b, "olla_endpoint_avg_latency_ms", "gauge", "Endpoint average latency in milliseconds") + writePrometheusHelpType(b, "olla_endpoint_traffic_bytes", "counter", "Total traffic for endpoint in bytes") + writePrometheusHelpType(b, "olla_endpoint_priority", "gauge", "Endpoint routing priority") + writePrometheusHelpType(b, "olla_endpoint_models_count", "gauge", "Number of models discovered on endpoint") + + for _, endpoint := range all { + url := endpoint.GetURLString() + stats, hasStats := statsMap[url] + status := endpoint.Status.String() + + var successRate float64 + requests := int64(0) + avgLatency := int64(0) + trafficBytes := int64(0) + if hasStats { + requests = stats.TotalRequests + avgLatency = stats.AverageLatency + trafficBytes = stats.TotalBytes + if stats.TotalRequests > 0 { + successRate = float64(stats.SuccessfulRequests) / float64(stats.TotalRequests) * 100.0 + } + } + + modelCount := int64(0) + if endpointModels := modelMap[url]; endpointModels != nil { + modelCount = int64(len(endpointModels.Models)) + } + + up := float64(0) + if endpoint.Status == domain.StatusHealthy { + up = 1 + } + + writePrometheusLabeledGauge(b, "olla_endpoint_up", up, "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_requests_total", float64(requests), "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_connections", float64(connectionStats[url]), "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_success_rate_percent", successRate, "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_avg_latency_ms", float64(avgLatency), "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_traffic_bytes", float64(trafficBytes), "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_priority", float64(endpoint.Priority), "endpoint", endpoint.Name, "status", status) + writePrometheusLabeledGauge(b, "olla_endpoint_models_count", float64(modelCount), "endpoint", endpoint.Name, "status", status) + } +} + +func totalTrafficBytes(snapshot *statusSnapshot) int64 { + var total int64 + for url, conn := range snapshot.connectionStats { + _ = conn + if stats, exists := snapshot.endpointStats[url]; exists { + total += stats.TotalBytes + } + } + return total +} + +func systemStatusValue(status string) float64 { + switch status { + case statusHealthy: + return 2 + case statusDegraded: + return 1 + default: + return 0 + } +} + +func securityStatusValue(status string) float64 { + if status == statusNormal { + return 1 + } + return 0 +} + +func parsePercentage(value string) float64 { + value = strings.TrimSpace(value) + value = strings.TrimSuffix(value, "%") + var parsed float64 + _, _ = fmt.Sscanf(value, "%f", &parsed) + return parsed +} + +func writePrometheusHelpType(b *strings.Builder, name, metricType, help string) { + b.WriteString("# HELP ") + b.WriteString(name) + b.WriteByte(' ') + b.WriteString(help) + b.WriteByte('\n') + b.WriteString("# TYPE ") + b.WriteString(name) + b.WriteByte(' ') + b.WriteString(metricType) + b.WriteByte('\n') +} + +func writePrometheusGauge(b *strings.Builder, name string, value float64) { + b.WriteString(name) + b.WriteByte(' ') + b.WriteString(formatPrometheusFloat(value)) + b.WriteByte('\n') +} + +func writePrometheusLabeledGauge(b *strings.Builder, name string, value float64, labels ...string) { + b.WriteString(name) + b.WriteByte('{') + for i := 0; i < len(labels); i += 2 { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(labels[i]) + b.WriteByte('=') + b.WriteByte('"') + b.WriteString(escapePrometheusLabel(labels[i+1])) + b.WriteByte('"') + } + b.WriteByte('}') + b.WriteByte(' ') + b.WriteString(formatPrometheusFloat(value)) + b.WriteByte('\n') +} + +func escapePrometheusLabel(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + value = strings.ReplaceAll(value, "\n", `\n`) + return value +} + +func formatPrometheusFloat(value float64) string { + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", value), "0"), ".") +} diff --git a/internal/app/handlers/handler_metrics_test.go b/internal/app/handlers/handler_metrics_test.go new file mode 100644 index 00000000..b1534ec0 --- /dev/null +++ b/internal/app/handlers/handler_metrics_test.go @@ -0,0 +1,181 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/core/constants" + "github.com/thushan/olla/internal/core/domain" + "github.com/thushan/olla/internal/core/ports" +) + +func TestMetricsHandler_BasicFunctionality(t *testing.T) { + t.Parallel() + + endpoints := []*domain.Endpoint{ + { + Name: "test-endpoint-healthy", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 100, + }, + { + Name: "test-endpoint-unhealthy", + Type: "openai", + URLString: "http://localhost:8080", + Status: domain.StatusUnhealthy, + Priority: 50, + }, + } + + stats := &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 120, + SuccessfulRequests: 118, + FailedRequests: 2, + TotalBytes: 4096, + AverageLatency: 125, + }, + }, + proxyStats: ports.ProxyStats{ + TotalRequests: 120, + SuccessfulRequests: 118, + FailedRequests: 2, + AverageLatency: 125, + }, + } + + app := &Application{ + repository: &mockStatusEndpointRepository{endpoints: endpoints}, + statsCollector: stats, + modelRegistry: &mockStatusModelRegistry{}, + StartTime: time.Now().Add(-2 * time.Hour), + Config: &config.Config{ + Proxy: config.ProxyConfig{ + Engine: "olla", + Profile: "auto", + LoadBalancer: "priority", + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, constants.DefaultMetricsEndpoint, nil) + w := httptest.NewRecorder() + + app.metricsHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, constants.ContentTypePrometheus, w.Header().Get(constants.HeaderContentType)) + + body := w.Body.String() + assert.Contains(t, body, "olla_requests_total") + assert.Contains(t, body, "olla_failures_total") + assert.Contains(t, body, "olla_endpoints_total 2") + assert.Contains(t, body, "olla_endpoints_healthy 1") + assert.Contains(t, body, `olla_endpoint_up{endpoint="test-endpoint-healthy",status="healthy"} 1`) + assert.Contains(t, body, `olla_endpoint_up{endpoint="test-endpoint-unhealthy",status="unhealthy"} 0`) + assert.Contains(t, body, `engine="olla"`) + assert.Contains(t, body, `balancer="priority"`) +} + +func TestMetricsHandler_MatchesStatusCounts(t *testing.T) { + t.Parallel() + + endpoints := []*domain.Endpoint{ + { + Name: "ep-a", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + }, + } + + stats := &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 10, + SuccessfulRequests: 9, + FailedRequests: 1, + TotalBytes: 1000, + AverageLatency: 50, + }, + }, + proxyStats: ports.ProxyStats{ + TotalRequests: 10, + SuccessfulRequests: 9, + FailedRequests: 1, + AverageLatency: 50, + }, + } + + app := &Application{ + repository: &mockStatusEndpointRepository{endpoints: endpoints}, + statsCollector: stats, + modelRegistry: &mockStatusModelRegistry{}, + StartTime: time.Now(), + Config: &config.Config{}, + } + + statusReq := httptest.NewRequest(http.MethodGet, "/internal/status", nil) + statusRec := httptest.NewRecorder() + app.statusHandler(statusRec, statusReq) + require.Equal(t, http.StatusOK, statusRec.Code) + + metricsReq := httptest.NewRequest(http.MethodGet, constants.DefaultMetricsEndpoint, nil) + metricsRec := httptest.NewRecorder() + app.metricsHandler(metricsRec, metricsReq) + require.Equal(t, http.StatusOK, metricsRec.Code) + + body := metricsRec.Body.String() + assert.Contains(t, body, "olla_requests_total 10") + assert.Contains(t, body, "olla_failures_total 1") + assert.Contains(t, body, "olla_avg_latency_ms 50") + assert.Contains(t, body, `olla_endpoint_requests_total{endpoint="ep-a",status="healthy"} 10`) +} + +func TestEscapePrometheusLabel(t *testing.T) { + t.Parallel() + + assert.Equal(t, `line1\nline2`, escapePrometheusLabel("line1\nline2")) + assert.Equal(t, `say \"hello\"`, escapePrometheusLabel(`say "hello"`)) + assert.Equal(t, `path\\to`, escapePrometheusLabel(`path\to`)) +} + +func TestFormatPrometheusFloat(t *testing.T) { + t.Parallel() + + assert.Equal(t, "125", formatPrometheusFloat(125)) + assert.Equal(t, "99.6", formatPrometheusFloat(99.6)) +} + +func TestParsePercentage(t *testing.T) { + t.Parallel() + + assert.InDelta(t, 99.2, parsePercentage("99.2%"), 0.001) + assert.InDelta(t, 0, parsePercentage("0.0%"), 0.001) +} + +func TestSystemStatusValue(t *testing.T) { + t.Parallel() + + assert.Equal(t, float64(2), systemStatusValue(statusHealthy)) + assert.Equal(t, float64(1), systemStatusValue(statusDegraded)) + assert.Equal(t, float64(0), systemStatusValue(statusCritical)) +} + +func TestWritePrometheusLabeledGauge(t *testing.T) { + t.Parallel() + + var b strings.Builder + writePrometheusLabeledGauge(&b, "olla_info", 1, "version", "0.0.28", "engine", "olla") + + assert.Equal(t, `olla_info{version="0.0.28",engine="olla"} 1`+"\n", b.String()) +} diff --git a/internal/app/handlers/handler_status.go b/internal/app/handlers/handler_status.go index 5933ef10..6d393cb2 100644 --- a/internal/app/handlers/handler_status.go +++ b/internal/app/handlers/handler_status.go @@ -89,36 +89,61 @@ type StatusResponse struct { System SystemSummary `json:"system"` } -func (a *Application) statusHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - now := time.Now() +type statusSnapshot struct { + all []*domain.Endpoint + healthy []*domain.Endpoint + endpointStats map[string]ports.EndpointStats + proxyStats ports.ProxyStats + securityStats ports.SecurityStats + connectionStats map[string]int64 + endpointModels map[string]*domain.EndpointModels +} +func (a *Application) gatherStatusSnapshot(ctx context.Context) (*statusSnapshot, error) { all, healthy, _, err := a.getEndpointCounts(ctx) if err != nil { - http.Error(w, fmt.Sprintf("Failed to get endpoint data: %v", err), http.StatusInternalServerError) - return + return nil, err } - endpointStatsMap := a.statsCollector.GetEndpointStats() - proxyStats := a.statsCollector.GetProxyStats() - securityStats := a.statsCollector.GetSecurityStats() - connectionStats := a.statsCollector.GetConnectionStats() endpointModelsMap, emErr := a.modelRegistry.GetEndpointModelMap(ctx) - if emErr != nil { - a.logger.Warn("Failed to get model map", "error", err) + a.logger.Warn("Failed to get model map", "error", emErr) endpointModelsMap = make(map[string]*domain.EndpointModels) } + return &statusSnapshot{ + all: all, + healthy: healthy, + endpointStats: a.statsCollector.GetEndpointStats(), + proxyStats: a.statsCollector.GetProxyStats(), + securityStats: a.statsCollector.GetSecurityStats(), + connectionStats: a.statsCollector.GetConnectionStats(), + endpointModels: endpointModelsMap, + }, nil +} + +func (a *Application) buildStatusResponse(snapshot *statusSnapshot) StatusResponse { response := StatusResponse{ - Timestamp: now, - Endpoints: make([]EndpointResponse, len(all)), + Timestamp: time.Now(), + Endpoints: make([]EndpointResponse, len(snapshot.all)), } response.Proxy = a.buildProxySummary(a.Config.Proxy) - response.System = a.buildSystemSummary(all, healthy, proxyStats, securityStats, connectionStats, endpointStatsMap) - a.buildUnifiedEndpoints(all, endpointStatsMap, connectionStats, response.Endpoints, endpointModelsMap) - response.Security = a.buildSecuritySummary(securityStats) + response.System = a.buildSystemSummary(snapshot.all, snapshot.healthy, snapshot.proxyStats, snapshot.securityStats, snapshot.connectionStats, snapshot.endpointStats) + a.buildUnifiedEndpoints(snapshot.all, snapshot.endpointStats, snapshot.connectionStats, response.Endpoints, snapshot.endpointModels) + response.Security = a.buildSecuritySummary(snapshot.securityStats) + + return response +} + +func (a *Application) statusHandler(w http.ResponseWriter, r *http.Request) { + snapshot, err := a.gatherStatusSnapshot(r.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get endpoint data: %v", err), http.StatusInternalServerError) + return + } + + response := a.buildStatusResponse(snapshot) w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) w.WriteHeader(http.StatusOK) diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index 9efff05a..50bcb116 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -68,6 +68,7 @@ func (m *mockStatusEndpointRepository) Exists(ctx context.Context, endpointURL * // mockStatusStatsCollector for status endpoint testing type mockStatusStatsCollector struct { endpointStats map[string]ports.EndpointStats + proxyStats ports.ProxyStats } func (m *mockStatusStatsCollector) RecordRequest(endpoint *domain.Endpoint, status string, latency time.Duration, bytes int64) { @@ -88,7 +89,7 @@ func (m *mockStatusStatsCollector) RecordTranslatorRequest(event ports.Translato func (m *mockStatusStatsCollector) GetTranslatorStats() map[string]ports.TranslatorStats { return nil } -func (m *mockStatusStatsCollector) GetProxyStats() ports.ProxyStats { return ports.ProxyStats{} } +func (m *mockStatusStatsCollector) GetProxyStats() ports.ProxyStats { return m.proxyStats } func (m *mockStatusStatsCollector) GetSecurityStats() ports.SecurityStats { return ports.SecurityStats{} } diff --git a/internal/app/handlers/handler_version.go b/internal/app/handlers/handler_version.go index 98655e3b..c90219ea 100644 --- a/internal/app/handlers/handler_version.go +++ b/internal/app/handlers/handler_version.go @@ -55,6 +55,7 @@ func (a *Application) versionHandler(w http.ResponseWriter, r *http.Request) { Version: "v1", Endpoints: map[string]string{ "health": "/internal/health", + "metrics": "/internal/metrics", "status": "/internal/status", "process": "/internal/process", "version": "/version", diff --git a/internal/app/handlers/server_routes.go b/internal/app/handlers/server_routes.go index 8478e2c5..48392def 100644 --- a/internal/app/handlers/server_routes.go +++ b/internal/app/handlers/server_routes.go @@ -31,6 +31,7 @@ func (a *Application) registerRoutes() { // for operations and shouldn't depend on any provider configuration a.routeRegistry.RegisterWithMethod(constants.DefaultHealthCheckEndpoint, a.healthHandler, "Health check endpoint", "GET") a.routeRegistry.RegisterWithMethod("/internal/status", a.statusHandler, "Endpoint status", "GET") + a.routeRegistry.RegisterWithMethod(constants.DefaultMetricsEndpoint, a.metricsHandler, "Prometheus metrics", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/endpoints", a.endpointsStatusHandler, "Endpoints status", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/models", a.modelsStatusHandler, "Models status", "GET") a.routeRegistry.RegisterWithMethod("/internal/stats/models", a.modelStatsHandler, "Model statistics", "GET") diff --git a/internal/core/constants/content.go b/internal/core/constants/content.go index 61aa953b..e79ffb8e 100644 --- a/internal/core/constants/content.go +++ b/internal/core/constants/content.go @@ -6,6 +6,7 @@ const ( // Standard Content Types ContentTypeJSON = "application/json" ContentTypeText = "text/plain" + ContentTypePrometheus = "text/plain; version=0.0.4; charset=utf-8" ContentTypeHTML = "text/html" ContentTypeXML = "application/xml" ContentTypeJavaScript = "application/javascript" diff --git a/internal/core/constants/endpoint.go b/internal/core/constants/endpoint.go index 54ed930e..c685632d 100644 --- a/internal/core/constants/endpoint.go +++ b/internal/core/constants/endpoint.go @@ -2,6 +2,7 @@ package constants const ( DefaultHealthCheckEndpoint = "/internal/health" + DefaultMetricsEndpoint = "/internal/metrics" DefaultOllaProxyPathPrefix = "/olla/" DefaultPathPrefix = "/" diff --git a/makefile b/makefile index 2501fda2..46ed131d 100644 --- a/makefile +++ b/makefile @@ -345,7 +345,12 @@ ci: deps fmt vet lint test-race test-cover build # Docker compose up with local config docker-compose: @echo "Starting with docker-compose..." - @docker-compose up + @docker compose up + +# Docker compose with local overlay (Prometheus + Grafana, local Olla image) +docker-compose-local: + @echo "Starting with docker-compose local overlay..." + @docker compose -f docker-compose.yaml -f docker-compose.local.yaml up # Run integration test scripts (requires running Olla instance) test-script-integration: @@ -441,7 +446,8 @@ help: @echo " build-snapshot - Build full release to ./dist/ (archives, checksums, etc)" @echo " docker-build - Build Docker images locally" @echo " docker-run - Run Docker image with local config" - @echo " docker-compose - Run with docker-compose" + @echo " docker-compose - Run with docker-compose" + @echo " docker-compose-local - Run with Prometheus/Grafana local overlay" @echo " release-test - Test full release (binaries + docker + archives)" @echo " goreleaser-check- Check goreleaser configuration" @echo " fmt - Format code"