package prom import ( "context" "encoding/json" "errors" "fmt" "net/url" "strconv" "time" ) // Client is a Prometheus HTTP API client that delegates all network calls to // the injected Transport. The Client itself is stateless with respect to // discovery — callers are responsible for constructing an appropriate // Transport (direct HTTP, kubectl port-forward, or any other tunnel). type Client struct { t Transport } const MaxWorkloadQueryBytes = 16000 var ErrWorkloadQueryTooLarge = errors.New("Pod-name selection or workload size exceeds Radar's safe metrics query limit") // NewClient wraps the given Transport. func NewClient(t Transport) *Client { return &Client{t: t} } // Query executes an instant PromQL query. func (c *Client) Query(ctx context.Context, promQL string) (*QueryResult, error) { return c.issueQuery(ctx, "/api/v1/query", url.Values{"query": {promQL}}) } // QueryAt executes an instant query at a fixed evidence time. func (c *Client) QueryAt(ctx context.Context, promQL string, at time.Time) (*QueryResult, error) { return c.issueQuery(ctx, "/api/v1/query", url.Values{"query": {promQL}, "time": {strconv.FormatInt(at.Unix(), 10)}}) } // QueryEvidence rejects partial answers rather than inferring identity from them. func (c *Client) QueryEvidence(ctx context.Context, promQL string) (*QueryResult, error) { if len(promQL) > MaxWorkloadQueryBytes { return nil, ErrWorkloadQueryTooLarge } body, err := c.t.Do(ctx, "POST", "/api/v1/query", url.Values{"query": {promQL}, "timeout": {"8s"}}) if err != nil { return nil, err } var response struct { Status string `json:"status"` Data json.RawMessage `json:"data"` Warnings []string `json:"warnings"` Infos []string `json:"infos"` IsPartial bool `json:"isPartial"` } if err := json.Unmarshal(body, &response); err != nil { return nil, err } if response.Status != "success" || len(response.Warnings) != 0 || len(response.Infos) != 0 || response.IsPartial { return nil, errors.New("metrics attribution probe did not return complete evidence") } return parseQueryResult(response.Data) } // QueryRange executes a PromQL range query. func (c *Client) QueryRange(ctx context.Context, promQL string, start, end time.Time, step time.Duration) (*QueryResult, error) { return c.queryRange(ctx, promQL, start, end, step, false) } func (c *Client) QueryWorkloadRange(ctx context.Context, promQL string, start, end time.Time, step time.Duration) (*QueryResult, error) { if len(promQL) > MaxWorkloadQueryBytes { return nil, ErrWorkloadQueryTooLarge } return c.queryRange(ctx, promQL, start, end, step, true) } func (c *Client) queryRange(ctx context.Context, promQL string, start, end time.Time, step time.Duration, complete bool) (*QueryResult, error) { params := url.Values{ "query": {promQL}, "start": {strconv.FormatInt(start.Unix(), 10)}, "end": {strconv.FormatInt(end.Unix(), 10)}, "step": {fmt.Sprintf("%.0f", step.Seconds())}, } if complete { return c.issueCompleteQuery(ctx, "POST", "/api/v1/query_range", params) } return c.issueQuery(ctx, "/api/v1/query_range", params) } var ErrPartialResponse = errors.New("metrics backend returned a partial response") var ErrQueryWarning = errors.New("metrics backend returned a query warning") func (c *Client) issueCompleteQuery(ctx context.Context, method, path string, params url.Values) (*QueryResult, error) { body, err := c.t.Do(ctx, method, path, params) if err != nil { return nil, err } var response promResponse if err := json.Unmarshal(body, &response); err != nil { return nil, err } if response.Status != "success" { return nil, fmt.Errorf("metrics query failed: %s", response.Error) } if response.IsPartial { return nil, ErrPartialResponse } if len(response.Warnings) > 0 { return nil, ErrQueryWarning } return parseQueryResult(response.Data) } func (c *Client) issueQuery(ctx context.Context, path string, params url.Values) (*QueryResult, error) { body, err := c.t.Do(ctx, "GET", path, params) if err != nil { return nil, err } var pr promResponse if err := json.Unmarshal(body, &pr); err != nil { return nil, fmt.Errorf("prom: parse response from %s: %w", c.t.Address(), err) } if pr.Status != "success" { return nil, fmt.Errorf("prom: query error from %s: %s (%s)", c.t.Address(), pr.Error, pr.ErrorType) } result, err := parseQueryResult(pr.Data) if err != nil { return nil, err } result.Annotations = QueryAnnotations{Warnings: pr.Warnings, Infos: pr.Infos, Partial: pr.IsPartial} return result, nil } // LabelValues returns values for a label via /api/v1/label/{label}/values. // match entries are PromQL series selectors; zero start/end skip the window // params; limit <= 0 skips the limit param. func (c *Client) LabelValues(ctx context.Context, label string, matches []string, start, end time.Time, limit int) ([]string, error) { params := url.Values{} for _, m := range matches { params.Add("match[]", m) } if !start.IsZero() { params.Set("start", strconv.FormatInt(start.Unix(), 10)) } if !end.IsZero() { params.Set("end", strconv.FormatInt(end.Unix(), 10)) } if limit > 0 { params.Set("limit", strconv.Itoa(limit)) } body, err := c.t.Do(ctx, "GET", "/api/v1/label/"+url.PathEscape(label)+"/values", params) if err != nil { return nil, err } var pr struct { Status string `json:"status"` Error string `json:"error"` ErrorType string `json:"errorType"` Data []string `json:"data"` } if err := json.Unmarshal(body, &pr); err != nil { return nil, fmt.Errorf("prom: parse label values from %s: %w", c.t.Address(), err) } if pr.Status != "success" { return nil, fmt.Errorf("prom: label values error from %s: %s (%s)", c.t.Address(), pr.Error, pr.ErrorType) } return pr.Data, nil } // MetricMetadata describes one metric as reported by /api/v1/metadata. type MetricMetadata struct { Type string `json:"type"` Help string `json:"help"` } // Metadata returns metric metadata via /api/v1/metadata as Prometheus reports // it: name → list of {type, help} (one entry per distinct target metadata; // targets can disagree). Collapsing the list to a single entry is the caller's // shaping decision. limit > 0 bounds the number of metrics returned // (/api/v1/metadata?limit=) so the whole catalog isn't pulled on large // backends; limit <= 0 fetches all. func (c *Client) Metadata(ctx context.Context, limit int) (map[string][]MetricMetadata, error) { params := url.Values{} if limit > 0 { params.Set("limit", strconv.Itoa(limit)) } body, err := c.t.Do(ctx, "GET", "/api/v1/metadata", params) if err != nil { return nil, err } var pr struct { Status string `json:"status"` Error string `json:"error"` ErrorType string `json:"errorType"` Data map[string][]MetricMetadata `json:"data"` } if err := json.Unmarshal(body, &pr); err != nil { return nil, fmt.Errorf("prom: parse metadata from %s: %w", c.t.Address(), err) } if pr.Status != "success" { return nil, fmt.Errorf("prom: metadata error from %s: %s (%s)", c.t.Address(), pr.Error, pr.ErrorType) } return pr.Data, nil } // RuleAlert is one active alert instance of an alerting rule. type RuleAlert struct { State string `json:"state"` ActiveAt string `json:"activeAt,omitempty"` Value string `json:"value,omitempty"` Labels map[string]string `json:"labels,omitempty"` } // Rule is a single alerting or recording rule within a RuleGroup. type Rule struct { Type string `json:"type"` // "alerting" or "recording" Name string `json:"name"` Query string `json:"query"` Duration float64 `json:"duration,omitempty"` // "for" clause, seconds State string `json:"state,omitempty"` // alerting: firing|pending|inactive Health string `json:"health,omitempty"` Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Alerts []RuleAlert `json:"alerts,omitempty"` } // RuleGroup is a named group of rules, the shape /api/v1/rules nests them in. type RuleGroup struct { Name string `json:"name"` File string `json:"file,omitempty"` Rules []Rule `json:"rules"` } // Rules returns the rule groups from /api/v1/rules as Prometheus nests them // (a named group holding its rules). typ filters server-side: "alert", // "record", or "" for both — older backends ignore the param, so callers must // not rely on it exclusively. Flattening or filtering the groups is the // caller's shaping decision. func (c *Client) Rules(ctx context.Context, typ string) ([]RuleGroup, error) { params := url.Values{} if typ != "" { params.Set("type", typ) } body, err := c.t.Do(ctx, "GET", "/api/v1/rules", params) if err != nil { return nil, err } var pr struct { Status string `json:"status"` Error string `json:"error"` ErrorType string `json:"errorType"` Data struct { Groups []RuleGroup `json:"groups"` } `json:"data"` } if err := json.Unmarshal(body, &pr); err != nil { return nil, fmt.Errorf("prom: parse rules from %s: %w", c.t.Address(), err) } if pr.Status != "success" { return nil, fmt.Errorf("prom: rules error from %s: %s (%s)", c.t.Address(), pr.Error, pr.ErrorType) } return pr.Data.Groups, nil } // ProbeReason explains a Probe result. An empty string on true = ok. // On false, Reason indicates why discovery should skip this candidate. type ProbeReason string const ( ProbeReasonTransportError ProbeReason = "transport_error" // no HTTP response: dial, TLS, timeout, connection reset ProbeReasonHTTPError ProbeReason = "http_error" // the endpoint answered with a non-2xx status other than 401/403 ProbeReasonAuthError ProbeReason = "auth_error" // HTTP 401/403 — credentials rejected ProbeReasonNotPrometheus ProbeReason = "not_prometheus" // 200 but response body isn't prom JSON (captive portal, login page) ProbeReasonPromError ProbeReason = "prom_error" // prom responded with status=error ProbeReasonEmptyInstance ProbeReason = "empty_instance" // prom responded success but zero "up" results ) // Probe checks if a Prometheus endpoint is reachable and has at least one // active scrape target. Returns (ok, reason). When ok is true the reason is // empty; when ok is false the reason indicates why (callers may use this // for targeted logging — e.g., warn once per empty-instance discovery // skip). // // Uses a 3-second timeout regardless of the context deadline to fail fast. func (c *Client) Probe(ctx context.Context) (bool, ProbeReason) { return c.probe(ctx, true) } // ProbeQueryAPI checks query health without requiring scrape metadata. Explicit // remote-write endpoints may deliberately omit up while retaining workload data. func (c *Client) ProbeQueryAPI(ctx context.Context) (bool, ProbeReason) { return c.probe(ctx, false) } func (c *Client) probe(ctx context.Context, requireTargets bool) (bool, ProbeReason) { probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() body, err := c.t.Do(probeCtx, "GET", "/api/v1/query", url.Values{"query": {"up"}}) if err != nil { var httpErr *HTTPError if errors.As(err, &httpErr) { if httpErr.StatusCode == 401 || httpErr.StatusCode == 403 { return false, ProbeReasonAuthError } var response struct { Status string `json:"status"` } if json.Unmarshal(httpErr.Body, &response) == nil && response.Status == "error" { return false, ProbeReasonPromError } // The server answered; whatever is wrong, the network path is not // it — callers reasoning about reachability must not treat this // like a dial failure. return false, ProbeReasonHTTPError } var respErr *ResponseError if errors.As(err, &respErr) { return false, ProbeReasonHTTPError } return false, ProbeReasonTransportError } var pr struct { Status string `json:"status"` Data struct { ResultType string `json:"resultType"` Result []json.RawMessage `json:"result"` } `json:"data"` } if err := json.Unmarshal(body, &pr); err != nil { return false, ProbeReasonNotPrometheus } if pr.Status != "success" { return false, ProbeReasonPromError } if !requireTargets && pr.Data.ResultType != "vector" { return false, ProbeReasonNotPrometheus } if requireTargets && len(pr.Data.Result) == 0 { return false, ProbeReasonEmptyInstance } return true, "" } func parseQueryResult(data json.RawMessage) (*QueryResult, error) { var head struct { ResultType string `json:"resultType"` Result json.RawMessage `json:"result"` } if err := json.Unmarshal(data, &head); err != nil { return nil, fmt.Errorf("prom: parse result: %w", err) } // A success response may omit "result" entirely (or send null) — some // PromQL-compatible backends and proxies do this for empty results. Treat // it as no data rather than a parse error, matching a bare Prometheus empty // result. if len(head.Result) == 0 || string(head.Result) == "null" { return &QueryResult{ResultType: head.ResultType, Series: []Series{}}, nil } // Scalar results (e.g. time(), scalar(sum(up))) are a single [ts, value] // pair rather than an array of labeled series — normalize to one labelless // series so callers see a uniform shape. if head.ResultType == "scalar" { var v []interface{} if err := json.Unmarshal(head.Result, &v); err != nil { return nil, fmt.Errorf("prom: parse scalar result: %w", err) } s := Series{Labels: map[string]string{}} if dp, ok := parseDataPoint(v); ok { s.DataPoints = []DataPoint{dp} } return &QueryResult{ResultType: "scalar", Series: []Series{s}}, nil } if head.ResultType == "string" { return nil, errors.New("prom: string result type is not supported — use a numeric query") } var raw struct { Result []struct { Metric map[string]string `json:"metric"` Values [][]interface{} `json:"values"` // matrix float samples Value []interface{} `json:"value"` // vector float sample } } if err := json.Unmarshal(head.Result, &raw.Result); err != nil { return nil, fmt.Errorf("prom: parse result: %w", err) } result := &QueryResult{ ResultType: head.ResultType, Series: make([]Series, 0, len(raw.Result)), } for _, r := range raw.Result { series := Series{Labels: r.Metric} switch head.ResultType { case "matrix": series.DataPoints = make([]DataPoint, 0, len(r.Values)) for _, v := range r.Values { if dp, ok := parseDataPoint(v); ok { series.DataPoints = append(series.DataPoints, dp) } } case "vector": if r.Value != nil { if dp, ok := parseDataPoint(r.Value); ok { series.DataPoints = []DataPoint{dp} } } } result.Series = append(result.Series, series) } return result, nil } func parseDataPoint(v []interface{}) (DataPoint, bool) { if len(v) != 2 { return DataPoint{}, false } ts, ok := v[0].(float64) if !ok { return DataPoint{}, false } valStr, sok := v[1].(string) if !sok { return DataPoint{}, false } val, err := strconv.ParseFloat(valStr, 64) if err != nil { return DataPoint{}, false } return DataPoint{Timestamp: int64(ts), Value: val}, true }