| 1 | // Package openmetrics provides a reusable OpenMetrics/Prometheus text protocol client. |
| 2 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 3 | |
| 4 | package openmetrics |
| 5 | |
| 6 | import ( |
| 7 | "bytes" |
| 8 | "compress/gzip" |
| 9 | "context" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "io" |
| 13 | "net/http" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/netdata/netdata/go/plugins/pkg/confopt" |
| 18 | "github.com/netdata/netdata/go/plugins/pkg/prometheus" |
| 19 | "github.com/netdata/netdata/go/plugins/pkg/prometheus/selector" |
| 20 | "github.com/netdata/netdata/go/plugins/pkg/web" |
| 21 | ) |
| 22 | |
| 23 | const defaultAcceptHeader = "text/plain;version=0.0.4;q=1,*/*;q=0.1" |
| 24 | |
| 25 | // Config describes how to communicate with an OpenMetrics endpoint. |
| 26 | type Config struct { |
| 27 | HTTPConfig web.HTTPConfig |
| 28 | // Accept allows overriding the Accept header advertised to the endpoint. |
| 29 | Accept string |
| 30 | } |
| 31 | |
| 32 | // Client fetches and parses OpenMetrics data from a single endpoint. |
| 33 | type Client struct { |
| 34 | cfg Config |
| 35 | httpClient *http.Client |
| 36 | request web.RequestConfig |
| 37 | |
| 38 | acceptHeader string |
| 39 | } |
| 40 | |
| 41 | // NewClient constructs a client with a freshly created HTTP transport. |
| 42 | func NewClient(cfg Config) (*Client, error) { |
| 43 | if time.Duration(cfg.HTTPConfig.ClientConfig.Timeout) <= 0 { |
| 44 | cfg.HTTPConfig.ClientConfig.Timeout = confopt.Duration(10 * time.Second) |
| 45 | } |
| 46 | |
| 47 | httpClient, err := web.NewHTTPClient(cfg.HTTPConfig.ClientConfig) |
| 48 | if err != nil { |
| 49 | return nil, fmt.Errorf("openmetrics protocol: creating http client failed: %w", err) |
| 50 | } |
| 51 | |
| 52 | return NewClientWithHTTP(cfg, httpClient) |
| 53 | } |
| 54 | |
| 55 | // NewClientWithHTTP constructs a client using the provided *http.Client instance (useful for tests). |
| 56 | func NewClientWithHTTP(cfg Config, httpClient *http.Client) (*Client, error) { |
| 57 | if httpClient == nil { |
| 58 | return nil, errors.New("openmetrics protocol: http client is required") |
| 59 | } |
| 60 | |
| 61 | trimmedURL := strings.TrimSpace(cfg.HTTPConfig.RequestConfig.URL) |
| 62 | if trimmedURL == "" { |
| 63 | return nil, errors.New("openmetrics protocol: url is required") |
| 64 | } |
| 65 | |
| 66 | accept := strings.TrimSpace(cfg.Accept) |
| 67 | if accept == "" { |
| 68 | accept = defaultAcceptHeader |
| 69 | } |
| 70 | |
| 71 | return &Client{ |
| 72 | cfg: cfg, |
| 73 | httpClient: httpClient, |
| 74 | request: cfg.HTTPConfig.RequestConfig.Copy(), |
| 75 | acceptHeader: accept, |
| 76 | }, nil |
| 77 | } |
| 78 | |
| 79 | // FetchSeries retrieves the metrics and returns them as a list of series samples. |
| 80 | func (c *Client) FetchSeries(ctx context.Context, sr selector.Selector) (prometheus.Series, error) { |
| 81 | payload, err := c.fetch(ctx) |
| 82 | if err != nil { |
| 83 | return nil, err |
| 84 | } |
| 85 | |
| 86 | parser := seriesParser{selector: sr} |
| 87 | return parser.parse(payload) |
| 88 | } |
| 89 | |
| 90 | func (c *Client) fetch(ctx context.Context) ([]byte, error) { |
| 91 | req, err := web.NewHTTPRequest(c.request) |
| 92 | if err != nil { |
| 93 | return nil, fmt.Errorf("openmetrics protocol: building request failed: %w", err) |
| 94 | } |
| 95 | |
| 96 | req = req.WithContext(ctx) |
| 97 | req.Header.Set("Accept", c.acceptHeader) |
| 98 | // Prefer gzip for large payloads but fall back gracefully. |
| 99 | req.Header.Set("Accept-Encoding", "gzip") |
| 100 | |
| 101 | resp, err := c.httpClient.Do(req) |
| 102 | if err != nil { |
| 103 | if ctx.Err() != nil { |
| 104 | return nil, fmt.Errorf("openmetrics protocol: request cancelled: %w", ctx.Err()) |
| 105 | } |
| 106 | return nil, fmt.Errorf("openmetrics protocol: request failed: %w", err) |
| 107 | } |
| 108 | defer web.CloseBody(resp) |
| 109 | |
| 110 | if resp.StatusCode != http.StatusOK { |
| 111 | snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) |
| 112 | return nil, fmt.Errorf("openmetrics protocol: unexpected status %d: %s", resp.StatusCode, string(snippet)) |
| 113 | } |
| 114 | |
| 115 | var reader io.Reader = resp.Body |
| 116 | if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { |
| 117 | gz, err := gzip.NewReader(resp.Body) |
| 118 | if err != nil { |
| 119 | return nil, fmt.Errorf("openmetrics protocol: creating gzip reader failed: %w", err) |
| 120 | } |
| 121 | defer gz.Close() |
| 122 | reader = gz |
| 123 | } |
| 124 | |
| 125 | buf := bytes.Buffer{} |
| 126 | if _, err := buf.ReadFrom(reader); err != nil { |
| 127 | if ctx.Err() != nil { |
| 128 | return nil, fmt.Errorf("openmetrics protocol: read cancelled: %w", ctx.Err()) |
| 129 | } |
| 130 | return nil, fmt.Errorf("openmetrics protocol: reading response failed: %w", err) |
| 131 | } |
| 132 | |
| 133 | return buf.Bytes(), nil |
| 134 | } |