Ctrl K
ComunidadGuías…

internal/promql/client.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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
}