internal/promql/client.go
Rama main · réplica de lectura del repositorio original.
// Package promql is a minimal client for the Prometheus HTTP query API.
//
// armdash reads every metric from Prometheus rather than from the machine
// directly. That costs a dependency but buys history and filtering for free,
// and keeps the dashboard stateless: it stores no samples of its own.
package promql
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
var ErrNotConfigured = errors.New("prometheus URL is not configured")
type Client struct {
// base is a func, not a string: the URL is editable in Settings at
// runtime and we want the next query to pick up the change.
base func() string
http *http.Client
}
func New(base func() string) *Client {
return &Client{base: base, http: &http.Client{Timeout: 10 * time.Second}}
}
// Sample is one instant value.
type Sample struct {
Labels map[string]string
Value float64
}
// Series is one labelled time series.
type Series struct {
Labels map[string]string
Points []Point
}
type Point struct {
T time.Time
V float64
}
type apiEnvelope struct {
Status string `json:"status"`
Error string `json:"error"`
ErrorType string `json:"errorType"`
Data json.RawMessage `json:"data"`
}
type resultBody struct {
ResultType string `json:"resultType"`
Result []struct {
Metric map[string]string `json:"metric"`
Value []any `json:"value"`
Values [][]any `json:"values"`
} `json:"result"`
}
func (c *Client) get(ctx context.Context, path string, q url.Values) (*resultBody, error) {
base := strings.TrimSuffix(c.base(), "/")
if base == "" {
return nil, ErrNotConfigured
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("querying prometheus: %w", err)
}
defer resp.Body.Close()
var env apiEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
// A non-JSON body almost always means the URL points at something
// that is not Prometheus, so say that rather than "invalid character".
return nil, fmt.Errorf("prometheus at %s returned a non-JSON response (HTTP %d)", base, resp.StatusCode)
}
if env.Status != "success" {
if env.Error != "" {
return nil, fmt.Errorf("prometheus: %s (%s)", env.Error, env.ErrorType)
}
return nil, fmt.Errorf("prometheus returned HTTP %d", resp.StatusCode)
}
var body resultBody
if err := json.Unmarshal(env.Data, &body); err != nil {
return nil, err
}
return &body, nil
}
// Query runs an instant query.
func (c *Client) Query(ctx context.Context, expr string) ([]Sample, error) {
body, err := c.get(ctx, "/api/v1/query", url.Values{"query": {expr}})
if err != nil {
return nil, err
}
out := make([]Sample, 0, len(body.Result))
for _, r := range body.Result {
v, ok := scalar(r.Value)
if !ok {
continue
}
out = append(out, Sample{Labels: r.Metric, Value: v})
}
return out, nil
}
// QueryOne runs an instant query expected to return a single value.
// Returns ok=false when the query returned nothing, which is not an error -
// a metric can legitimately be absent (no such device, exporter still starting).
func (c *Client) QueryOne(ctx context.Context, expr string) (float64, bool, error) {
s, err := c.Query(ctx, expr)
if err != nil {
return 0, false, err
}
if len(s) == 0 {
return 0, false, nil
}
return s[0].Value, true, nil
}
// QueryRange runs a range query.
func (c *Client) QueryRange(ctx context.Context, expr string, start, end time.Time, step time.Duration) ([]Series, error) {
q := url.Values{
"query": {expr},
"start": {strconv.FormatInt(start.Unix(), 10)},
"end": {strconv.FormatInt(end.Unix(), 10)},
"step": {strconv.Itoa(int(step.Seconds()))},
}
body, err := c.get(ctx, "/api/v1/query_range", q)
if err != nil {
return nil, err
}
out := make([]Series, 0, len(body.Result))
for _, r := range body.Result {
s := Series{Labels: r.Metric, Points: make([]Point, 0, len(r.Values))}
for _, pair := range r.Values {
v, ok := scalar(pair)
if !ok {
continue
}
ts, _ := pair[0].(float64)
s.Points = append(s.Points, Point{T: time.Unix(int64(ts), 0), V: v})
}
out = append(out, s)
}
return out, nil
}
// scalar pulls the value out of Prometheus's [timestamp, "value"] pair.
// The value arrives as a *string*, including "NaN", "+Inf" and "-Inf".
func scalar(pair []any) (float64, bool) {
if len(pair) != 2 {
return 0, false
}
s, ok := pair[1].(string)
if !ok {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
}