master
go 134 lines 3.81 KB
Raw
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 }