Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion domains/platform/apis/prom_proxy/container_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (h *MetricsHandler) GetContainerTimeSeries(w http.ResponseWriter, r *http.R
requested := r.PathValue("name")
rangeParam := r.PathValue("range")
if !ValidTimeRange(rangeParam) {
mucks.JsonError(w, mucks.NewBadRequest("Invalid time range. Valid options: 30m, 1d, 7d"))
mucks.JsonError(w, mucks.NewBadRequest(badTimeRangeDetail))
return
}
timeRange := TimeRange(rangeParam)
Expand Down
20 changes: 17 additions & 3 deletions domains/platform/apis/prom_proxy/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ func GetTimeRangeConfig(timeRange TimeRange) (duration time.Duration, step strin
}
}

// DefaultRange is what a scalar request that names no range gets: the
// dashboard's own default, a day.
const DefaultRange = LastDay

// What a request naming a range this package does not build gets told.
const badTimeRangeDetail = "Invalid time range. Valid options: 30m, 1d, 7d"

// Window is this range as the lookback its tiles read over. The range names
// are already PromQL durations, so the window is the name itself.
func (tr TimeRange) Window() string { return string(tr) }

// ValidTimeRange checks if a time range string is valid
func ValidTimeRange(tr string) bool {
switch TimeRange(tr) {
Expand Down Expand Up @@ -157,8 +168,8 @@ type HostMetricsResponse struct {
type StandardMetrics struct {
RequestsTotal float64 `json:"requests_total"`
RatePerSec float64 `json:"rate_per_sec"`
SuccessCount5m float64 `json:"success_count_5m"`
FailureCount5m float64 `json:"failure_count_5m"`
SuccessCount float64 `json:"success_count"`
FailureCount float64 `json:"failure_count"`
ErrorRatePercent float64 `json:"error_rate_percent"`
AvgDurationMicros float64 `json:"avg_duration_microseconds"`
P95DurationMicros float64 `json:"p95_duration_microseconds"`
Expand Down Expand Up @@ -190,7 +201,10 @@ type ServiceMetricsResponse struct {
// implicit so a client that sent no ?view= still knows what it is looking
// at, and so the default can move without a silent reinterpretation of
// every counter tile on the page.
View string `json:"view"`
View string `json:"view"`
// The window every windowed tile was computed over, as the PromQL
// duration the queries carried. Echoed for the same reason View is.
Window string `json:"window"`
Custom []CustomMetricGroup `json:"custom"`
}

Expand Down
12 changes: 12 additions & 0 deletions domains/platform/apis/prom_proxy/models_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package prom_proxy

import (
"regexp"
"testing"
"time"

Expand Down Expand Up @@ -49,6 +50,17 @@ func TestGetTimeRangeConfig(t *testing.T) {
}
}

// The window a range's tiles read over is the range's own name: each one is
// a PromQL duration as written, and the default is the dashboard's, a day.
func TestTimeRangeWindow(t *testing.T) {
promDuration := regexp.MustCompile(`^[0-9]+[smhdwy]$`)
for _, tr := range []TimeRange{Last30Minutes, LastDay, LastWeek} {
assert.Equal(t, string(tr), tr.Window())
assert.Regexp(t, promDuration, tr.Window())
}
assert.Equal(t, "1d", DefaultRange.Window())
}

func TestValidTimeRange(t *testing.T) {
tests := []struct {
name string
Expand Down
269 changes: 122 additions & 147 deletions domains/platform/apis/prom_proxy/registry.go

Large diffs are not rendered by default.

233 changes: 91 additions & 142 deletions domains/platform/apis/prom_proxy/registry_test.go

Large diffs are not rendered by default.

34 changes: 28 additions & 6 deletions domains/platform/apis/prom_proxy/service_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package prom_proxy

import (
"context"
"log"
"net/http"
"sort"
"strings"
Expand Down Expand Up @@ -50,7 +51,7 @@ func (h *MetricsHandler) GetHostMetricsTimeSeries(w http.ResponseWriter, r *http
timeRange := r.PathValue("range")

if !ValidTimeRange(timeRange) {
problem := mucks.NewBadRequest("Invalid time range. Valid options: 30m, 1d, 7d")
problem := mucks.NewBadRequest(badTimeRangeDetail)
mucks.JsonError(w, problem)
return
}
Expand Down Expand Up @@ -102,6 +103,18 @@ func (h *MetricsHandler) GetServiceMetrics(w http.ResponseWriter, r *http.Reques
}
view = MetricView(raw)
}
// The range the tiles read over, the same one the timeseries route takes
// in its path, so the dashboard asks both for the window it is showing.
timeRange := DefaultRange
if raw := r.URL.Query().Get("range"); raw != "" {
if !ValidTimeRange(raw) {
problem := mucks.NewBadRequest(badTimeRangeDetail)
mucks.JsonError(w, problem)
return
}
timeRange = TimeRange(raw)
}
window := timeRange.Window()

ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
Expand All @@ -110,12 +123,19 @@ func (h *MetricsHandler) GetServiceMetrics(w http.ResponseWriter, r *http.Reques
Timestamp: time.Now().UTC(),
Service: name,
View: string(view),
Window: window,
Custom: []CustomMetricGroup{},
}

for _, q := range standardScalarQueries(name) {
// A query Prometheus refuses — a week-long lookback past its sample
// budget, say — leaves its tile at zero, which is the outage contract
// below; the log is the one place that zero is told apart from a real
// one.
for _, q := range standardScalarQueries(name, window) {
resp, err := h.promClient.Query(ctx, q.Query)
if err == nil && len(resp.Data.Result) > 0 {
if err != nil {
log.Printf("service %s: %s: %v", name, q.Query, err)
} else if len(resp.Data.Result) > 0 {
if val, err := extractFloatValue(&resp.Data.Result[0]); err == nil {
*q.Field(&response.Standard) = val
}
Expand All @@ -127,8 +147,10 @@ func (h *MetricsHandler) GetServiceMetrics(w http.ResponseWriter, r *http.Reques
groupIndex := map[string]int{}
for _, def := range entry.CustomScalars {
value := 0.0
resp, err := h.promClient.Query(ctx, def.QueryFor(view))
if err == nil && len(resp.Data.Result) > 0 {
resp, err := h.promClient.Query(ctx, def.QueryFor(view, window))
if err != nil {
log.Printf("service %s tile %s: %v", name, def.Label, err)
} else if len(resp.Data.Result) > 0 {
if val, err := extractFloatValue(&resp.Data.Result[0]); err == nil {
value = val
}
Expand Down Expand Up @@ -160,7 +182,7 @@ func (h *MetricsHandler) GetServiceMetricsTimeSeries(w http.ResponseWriter, r *h

timeRange := r.PathValue("range")
if !ValidTimeRange(timeRange) {
problem := mucks.NewBadRequest("Invalid time range. Valid options: 30m, 1d, 7d")
problem := mucks.NewBadRequest(badTimeRangeDetail)
mucks.JsonError(w, problem)
return
}
Expand Down
71 changes: 61 additions & 10 deletions domains/platform/apis/prom_proxy/service_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func TestMetricsHandler_GetServiceMetrics_MapsEveryFieldDistinctly(t *testing.T)
// or custom descriptor fails loudly. One custom query is deliberately
// omitted from the mock — its descriptor must still appear, zeroed.
responses := map[string]*QueryResponse{}
standard := standardScalarQueries("games_hub")
standard := standardScalarQueries("games_hub", DefaultRange.Window())
for i, q := range standard {
responses[q.Query] = scalarResponse(fmt.Sprintf("%d", 100+i))
}
Expand All @@ -88,7 +88,7 @@ func TestMetricsHandler_GetServiceMetrics_MapsEveryFieldDistinctly(t *testing.T)
if def == omitted {
continue
}
responses[def.QueryFor(DefaultView)] = scalarResponse(fmt.Sprintf("%d", 200+i))
responses[def.QueryFor(DefaultView, DefaultRange.Window())] = scalarResponse(fmt.Sprintf("%d", 200+i))
}

handler := &MetricsHandler{promClient: &mockPrometheusClient{queryResponses: responses}}
Expand All @@ -109,16 +109,18 @@ func TestMetricsHandler_GetServiceMetrics_MapsEveryFieldDistinctly(t *testing.T)
// Standard fields in declaration order of standardScalarQueries.
assert.Equal(t, 100.0, response.Standard.RequestsTotal)
assert.Equal(t, 101.0, response.Standard.RatePerSec)
assert.Equal(t, 102.0, response.Standard.SuccessCount5m)
assert.Equal(t, 103.0, response.Standard.FailureCount5m)
assert.Equal(t, 102.0, response.Standard.SuccessCount)
assert.Equal(t, 103.0, response.Standard.FailureCount)
assert.Equal(t, 104.0, response.Standard.ErrorRatePercent)
assert.Equal(t, 105.0, response.Standard.AvgDurationMicros)
assert.Equal(t, 106.0, response.Standard.P95DurationMicros)
assert.Equal(t, 107.0, response.Standard.ActiveRequests)

// An unasked-for view is the default, and it is stated rather than left
// for the client to assume.
// for the client to assume. The range likewise: a day, echoed as the
// window every tile above was read over.
assert.Equal(t, string(DefaultView), response.View)
assert.Equal(t, "1d", response.Window)

// Custom groups keep registry order and every descriptor is present.
require.Len(t, response.Custom, 7)
Expand Down Expand Up @@ -174,7 +176,7 @@ func TestMetricsHandler_GetServiceMetrics_NoCustomServiceKeepsEmptyArray(t *test

mockClient := &mockPrometheusClient{
queryResponses: map[string]*QueryResponse{
`sum(rate(http_server_requests_total{service_name="fixture_svc",route!="/health"}[5m]))`: scalarResponse("2.5"),
`sum(rate(http_server_requests_total{service_name="fixture_svc",route!="/health"}[1d]))`: scalarResponse("2.5"),
},
}

Expand Down Expand Up @@ -224,6 +226,55 @@ func TestMetricsHandler_GetServiceMetrics_PrometheusError(t *testing.T) {
}
}

// ?range= is the window every tile reads over, standard and custom alike,
// and the response says which one it used.
func TestMetricsHandler_GetServiceMetrics_RangeWindowsEveryTile(t *testing.T) {
responses := map[string]*QueryResponse{}
for i, q := range standardScalarQueries("games_hub", "7d") {
responses[q.Query] = scalarResponse(fmt.Sprintf("%d", 100+i))
}
entry := serviceRegistry["games_hub"]
for i, def := range entry.CustomScalars {
responses[def.QueryFor(ViewRate, "7d")] = scalarResponse(fmt.Sprintf("%d", 200+i))
}
handler := &MetricsHandler{promClient: &mockPrometheusClient{queryResponses: responses}}

req := httptest.NewRequest("GET", "/metrics/v1/service/games_hub?view=rate&range=7d", nil)
req.SetPathValue("name", "games_hub")
w := httptest.NewRecorder()
handler.GetServiceMetrics(w, req)
assert.Equal(t, http.StatusOK, w.Code)

var response ServiceMetricsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, "7d", response.Window)
assert.Equal(t, 100.0, response.Standard.RequestsTotal)
assert.Equal(t, 107.0, response.Standard.ActiveRequests)
// Every custom tile answered from a 7d query: none fell back to zero.
i := 0
for _, group := range response.Custom {
for _, metric := range group.Metrics {
assert.Equal(t, float64(200+i), metric.Value, metric.Label)
i++
}
}
assert.Equal(t, len(entry.CustomScalars), i)
}

func TestMetricsHandler_GetServiceMetrics_InvalidRange(t *testing.T) {
handler := &MetricsHandler{promClient: &mockPrometheusClient{}}

req := httptest.NewRequest("GET", "/metrics/v1/service/games_hub?range=2h", nil)
req.SetPathValue("name", "games_hub")
w := httptest.NewRecorder()
handler.GetServiceMetrics(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Contains(t, response["detail"], "Invalid time range")
}

func TestMetricsHandler_GetServiceMetrics_UnknownService(t *testing.T) {
handler := &MetricsHandler{}

Expand Down Expand Up @@ -590,7 +641,7 @@ func TestMetricsHandler_GetServiceMetrics_RateViewSelectsTheRateForm(t *testing.
entry := serviceRegistry["games_hub"]
responses := map[string]*QueryResponse{}
for i, def := range entry.CustomScalars {
responses[def.QueryFor(ViewRate)] = scalarResponse(fmt.Sprintf("%d", 300+i))
responses[def.QueryFor(ViewRate, DefaultRange.Window())] = scalarResponse(fmt.Sprintf("%d", 300+i))
}

handler := &MetricsHandler{promClient: &mockPrometheusClient{queryResponses: responses}}
Expand Down Expand Up @@ -623,8 +674,8 @@ func TestMetricsHandler_GetServiceMetrics_RateViewSelectsTheRateForm(t *testing.
// the windowed mean is already a ratio of two rates.
assert.Equal(t, "sessions", byLabel["hub_active"].Unit)
assert.False(t, byLabel["hub_active"].Toggleable)
assert.Equal(t, "rows", byLabel["catch_up_rows_avg_5m"].Unit)
assert.False(t, byLabel["catch_up_rows_avg_5m"].Toggleable)
assert.Equal(t, "rows", byLabel["catch_up_rows_avg"].Unit)
assert.False(t, byLabel["catch_up_rows_avg"].Toggleable)
}

func TestMetricsHandler_GetServiceMetrics_InvalidViewIsRejected(t *testing.T) {
Expand Down Expand Up @@ -659,7 +710,7 @@ func TestMetricsHandler_GetServiceMetrics_JsonKeysAreStable(t *testing.T) {
entry := serviceRegistry["games_hub"]
responses := map[string]*QueryResponse{}
for i, def := range entry.CustomScalars {
responses[def.QueryFor(DefaultView)] = scalarResponse(fmt.Sprintf("%d", 400+i))
responses[def.QueryFor(DefaultView, DefaultRange.Window())] = scalarResponse(fmt.Sprintf("%d", 400+i))
}

handler := &MetricsHandler{promClient: &mockPrometheusClient{queryResponses: responses}}
Expand Down
Loading