Prometheus: Introduce prometheus backend library (#83952)
* Move files to prometheus-library * refactor core prometheus to use prometheus-library * modify client transport options * mock * have a type * import aliases * rename * call the right method * remove unrelated test from the library * update codeowners * go work sync * update go.work.sum * make swagger-clean && make openapi3-gen * add promlib to makefile * remove clilogger * Export the function * update unit test * add prometheus_test.go * fix mock type * use mapUtil from grafana-plugin-sdk-go
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
)
|
||||
|
||||
type doer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Client is a custom Prometheus client. Reason for this is that Prom Go client serializes response into its own
|
||||
// objects, we have to go through them and then serialize again into DataFrame which isn't very efficient. Using custom
|
||||
// client we can parse response directly into DataFrame.
|
||||
type Client struct {
|
||||
doer doer
|
||||
method string
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewClient(d doer, method, baseUrl string) *Client {
|
||||
return &Client{doer: d, method: method, baseUrl: baseUrl}
|
||||
}
|
||||
|
||||
func (c *Client) QueryRange(ctx context.Context, q *models.Query) (*http.Response, error) {
|
||||
tr := q.TimeRange()
|
||||
qv := map[string]string{
|
||||
"query": q.Expr,
|
||||
"start": formatTime(tr.Start),
|
||||
"end": formatTime(tr.End),
|
||||
"step": strconv.FormatFloat(tr.Step.Seconds(), 'f', -1, 64),
|
||||
}
|
||||
|
||||
req, err := c.createQueryRequest(ctx, "api/v1/query_range", qv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.doer.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) QueryInstant(ctx context.Context, q *models.Query) (*http.Response, error) {
|
||||
// We do not need a time range here.
|
||||
// Instant query evaluates at a single point in time.
|
||||
// Using q.TimeRange is aligning the query range to step.
|
||||
// Which causes a misleading time point.
|
||||
// Instead of aligning we use time point directly.
|
||||
// https://prometheus.io/docs/prometheus/latest/querying/api/#instant-queries
|
||||
qv := map[string]string{"query": q.Expr, "time": formatTime(q.End)}
|
||||
req, err := c.createQueryRequest(ctx, "api/v1/query", qv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.doer.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) QueryExemplars(ctx context.Context, q *models.Query) (*http.Response, error) {
|
||||
tr := q.TimeRange()
|
||||
qv := map[string]string{
|
||||
"query": q.Expr,
|
||||
"start": formatTime(tr.Start),
|
||||
"end": formatTime(tr.End),
|
||||
}
|
||||
|
||||
req, err := c.createQueryRequest(ctx, "api/v1/query_exemplars", qv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.doer.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) QueryResource(ctx context.Context, req *backend.CallResourceRequest) (*http.Response, error) {
|
||||
// The way URL is represented in CallResourceRequest and what we need for the fetch function is different
|
||||
// so here we have to do a bit of parsing, so we can then compose it with the base url in correct way.
|
||||
reqUrlParsed, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := c.createUrl(req.Path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.RawQuery = reqUrlParsed.RawQuery
|
||||
|
||||
// We use method from the request, as for resources front end may do a fallback to GET if POST does not work
|
||||
// nad we want to respect that.
|
||||
httpRequest, err := createRequest(ctx, req.Method, u, bytes.NewReader(req.Body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.doer.Do(httpRequest)
|
||||
}
|
||||
|
||||
func (c *Client) createQueryRequest(ctx context.Context, endpoint string, qv map[string]string) (*http.Request, error) {
|
||||
if strings.ToUpper(c.method) == http.MethodPost {
|
||||
u, err := c.createUrl(endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := make(url.Values)
|
||||
for key, val := range qv {
|
||||
v.Set(key, val)
|
||||
}
|
||||
|
||||
return createRequest(ctx, c.method, u, strings.NewReader(v.Encode()))
|
||||
}
|
||||
|
||||
u, err := c.createUrl(endpoint, qv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return createRequest(ctx, c.method, u, http.NoBody)
|
||||
}
|
||||
|
||||
func (c *Client) createUrl(endpoint string, qs map[string]string) (*url.URL, error) {
|
||||
finalUrl, err := url.ParseRequestURI(c.baseUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
finalUrl.Path = path.Join(finalUrl.Path, endpoint)
|
||||
|
||||
// don't re-encode the Query if not needed
|
||||
if len(qs) != 0 {
|
||||
urlQuery := finalUrl.Query()
|
||||
|
||||
for key, val := range qs {
|
||||
urlQuery.Set(key, val)
|
||||
}
|
||||
|
||||
finalUrl.RawQuery = urlQuery.Encode()
|
||||
}
|
||||
|
||||
return finalUrl, nil
|
||||
}
|
||||
|
||||
func createRequest(ctx context.Context, method string, u *url.URL, bodyReader io.Reader) (*http.Request, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.ToUpper(method) == http.MethodPost {
|
||||
// This may not be true but right now we don't have more information here and seems like we send just this type
|
||||
// of encoding right now if it is a POST
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// This allows transport to retry request. See https://github.com/prometheus/client_golang/pull/1022
|
||||
// It's set to nil so it is not actually sent over the wire, just used in Go http lib to retry requests.
|
||||
request.Header["Idempotency-Key"] = nil
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
return strconv.FormatFloat(float64(t.Unix())+float64(t.Nanosecond())/1e9, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/promlib/models"
|
||||
)
|
||||
|
||||
type MockDoer struct {
|
||||
Req *http.Request
|
||||
}
|
||||
|
||||
func (doer *MockDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
doer.Req = req
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestClient(t *testing.T) {
|
||||
t.Run("QueryResource", func(t *testing.T) {
|
||||
doer := &MockDoer{}
|
||||
// The method here does not really matter for resource calls
|
||||
client := NewClient(doer, http.MethodGet, "http://localhost:9090")
|
||||
|
||||
t.Run("sends correct POST request", func(t *testing.T) {
|
||||
req := &backend.CallResourceRequest{
|
||||
PluginContext: backend.PluginContext{},
|
||||
Path: "/api/v1/series",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/v1/series",
|
||||
Body: []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"),
|
||||
}
|
||||
res, err := client.QueryResource(context.Background(), req)
|
||||
defer func() {
|
||||
if res != nil && res.Body != nil {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
fmt.Println("Error", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, doer.Req)
|
||||
require.Equal(t, http.MethodPost, doer.Req.Method)
|
||||
body, err := io.ReadAll(doer.Req.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("match%5B%5D: ALERTS\nstart: 1655271408\nend: 1655293008"), body)
|
||||
require.Equal(t, "http://localhost:9090/api/v1/series", doer.Req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("sends correct GET request", func(t *testing.T) {
|
||||
req := &backend.CallResourceRequest{
|
||||
PluginContext: backend.PluginContext{},
|
||||
Path: "/api/v1/series",
|
||||
Method: http.MethodGet,
|
||||
URL: "api/v1/series?match%5B%5D=ALERTS&start=1655272558&end=1655294158",
|
||||
}
|
||||
res, err := client.QueryResource(context.Background(), req)
|
||||
defer func() {
|
||||
if res != nil && res.Body != nil {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
fmt.Println("Error", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, doer.Req)
|
||||
require.Equal(t, http.MethodGet, doer.Req.Method)
|
||||
body, err := io.ReadAll(doer.Req.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{}, body)
|
||||
require.Equal(t, "http://localhost:9090/api/v1/series?match%5B%5D=ALERTS&start=1655272558&end=1655294158", doer.Req.URL.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("QueryRange", func(t *testing.T) {
|
||||
doer := &MockDoer{}
|
||||
|
||||
t.Run("sends correct POST query", func(t *testing.T) {
|
||||
client := NewClient(doer, http.MethodPost, "http://localhost:9090")
|
||||
req := &models.Query{
|
||||
Expr: "rate(ALERTS{job=\"test\" [$__rate_interval]})",
|
||||
Start: time.Unix(0, 0),
|
||||
End: time.Unix(1234, 0),
|
||||
RangeQuery: true,
|
||||
Step: 1 * time.Second,
|
||||
}
|
||||
res, err := client.QueryRange(context.Background(), req)
|
||||
defer func() {
|
||||
if res != nil && res.Body != nil {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
fmt.Println("Error", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, doer.Req)
|
||||
require.Equal(t, http.MethodPost, doer.Req.Method)
|
||||
require.Equal(t, "application/x-www-form-urlencoded", doer.Req.Header.Get("Content-Type"))
|
||||
body, err := io.ReadAll(doer.Req.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("end=1234&query=rate%28ALERTS%7Bjob%3D%22test%22+%5B%24__rate_interval%5D%7D%29&start=0&step=1"), body)
|
||||
require.Equal(t, "http://localhost:9090/api/v1/query_range", doer.Req.URL.String())
|
||||
})
|
||||
|
||||
t.Run("sends correct GET query", func(t *testing.T) {
|
||||
client := NewClient(doer, http.MethodGet, "http://localhost:9090")
|
||||
req := &models.Query{
|
||||
Expr: "rate(ALERTS{job=\"test\" [$__rate_interval]})",
|
||||
Start: time.Unix(0, 0),
|
||||
End: time.Unix(1234, 0),
|
||||
RangeQuery: true,
|
||||
Step: 1 * time.Second,
|
||||
}
|
||||
res, err := client.QueryRange(context.Background(), req)
|
||||
defer func() {
|
||||
if res != nil && res.Body != nil {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
fmt.Println("Error", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, doer.Req)
|
||||
require.Equal(t, http.MethodGet, doer.Req.Method)
|
||||
body, err := io.ReadAll(doer.Req.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{}, body)
|
||||
require.Equal(t, "http://localhost:9090/api/v1/query_range?end=1234&query=rate%28ALERTS%7Bjob%3D%22test%22+%5B%24__rate_interval%5D%7D%29&start=0&step=1", doer.Req.URL.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/utils/maputil"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
|
||||
"github.com/grafana/grafana/pkg/promlib/middleware"
|
||||
"github.com/grafana/grafana/pkg/promlib/utils"
|
||||
)
|
||||
|
||||
// CreateTransportOptions creates options for the http client.
|
||||
func CreateTransportOptions(ctx context.Context, settings backend.DataSourceInstanceSettings, logger log.Logger) (*sdkhttpclient.Options, error) {
|
||||
opts, err := settings.HTTPClientOptions(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting HTTP options: %w", err)
|
||||
}
|
||||
|
||||
jsonData, err := utils.GetJsonData(settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading settings: %w", err)
|
||||
}
|
||||
httpMethod, _ := maputil.GetStringOptional(jsonData, "httpMethod")
|
||||
|
||||
opts.Middlewares = middlewares(logger, httpMethod)
|
||||
|
||||
return &opts, nil
|
||||
}
|
||||
|
||||
func middlewares(logger log.Logger, httpMethod string) []sdkhttpclient.Middleware {
|
||||
middlewares := []sdkhttpclient.Middleware{
|
||||
// TODO: probably isn't needed anymore and should by done by http infra code
|
||||
middleware.CustomQueryParameters(logger),
|
||||
sdkhttpclient.CustomHeadersMiddleware(),
|
||||
}
|
||||
|
||||
// Needed to control GET vs POST method of the requests
|
||||
if strings.ToLower(httpMethod) == "get" {
|
||||
middlewares = append(middlewares, middleware.ForceHttpGet(logger))
|
||||
}
|
||||
|
||||
return middlewares
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateTransportOptions(t *testing.T) {
|
||||
t.Run("creates correct options object", func(t *testing.T) {
|
||||
settings := backend.DataSourceInstanceSettings{
|
||||
BasicAuthEnabled: false,
|
||||
BasicAuthUser: "",
|
||||
JSONData: []byte(`{"httpHeaderName1": "foo"}`),
|
||||
DecryptedSecureJSONData: map[string]string{
|
||||
"httpHeaderValue1": "bar",
|
||||
},
|
||||
}
|
||||
opts, err := CreateTransportOptions(context.Background(), settings, backend.NewLoggerWith("logger", "test"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{"foo": "bar"}, opts.Headers)
|
||||
require.Equal(t, 2, len(opts.Middlewares))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user