mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Phlare: Support both Phlare and Pyroscope backends (#66989)
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -193,6 +193,7 @@
|
||||
/devenv/docker/blocks/postgres_tests/ @grafana/grafana-bi-squad
|
||||
/devenv/docker/blocks/prometheus/ @grafana/observability-metrics
|
||||
/devenv/docker/blocks/prometheus_random_data/ @grafana/observability-metrics
|
||||
/devenv/docker/blocks/pyroscope/ @grafana/observability-traces-and-profiling
|
||||
/devenv/docker/blocks/redis/ @bergquist
|
||||
/devenv/docker/blocks/sensugo/ @grafana/backend-platform
|
||||
/devenv/docker/blocks/slow_proxy/ @bergquist
|
||||
|
||||
6
devenv/docker/blocks/pyroscope/docker-compose.yaml
Normal file
6
devenv/docker/blocks/pyroscope/docker-compose.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
pyroscope:
|
||||
image: "pyroscope/pyroscope:latest"
|
||||
command:
|
||||
- "server"
|
||||
ports:
|
||||
- "4040:4040"
|
||||
@@ -76,6 +76,10 @@ func TestIntegrationPlugins(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
//
|
||||
// NOTE:
|
||||
// If this test is failing due to changes in plugins just rerun with updateSnapshotFlag = true at the top.
|
||||
//
|
||||
t.Run("List", func(t *testing.T) {
|
||||
testCases := []testCase{
|
||||
{
|
||||
|
||||
@@ -643,6 +643,47 @@
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
},
|
||||
{
|
||||
"name": "Grafana Pyroscope",
|
||||
"type": "datasource",
|
||||
"id": "phlare",
|
||||
"enabled": true,
|
||||
"pinned": false,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://www.grafana.com"
|
||||
},
|
||||
"description": "Supports Phlare and Pyroscope backends, horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation systems.",
|
||||
"links": [
|
||||
{
|
||||
"name": "GitHub Project",
|
||||
"url": "https://github.com/grafana/phlare"
|
||||
}
|
||||
],
|
||||
"logos": {
|
||||
"small": "public/app/plugins/datasource/phlare/img/grafana_pyroscope_icon.svg",
|
||||
"large": "public/app/plugins/datasource/phlare/img/grafana_pyroscope_icon.svg"
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "",
|
||||
"updated": ""
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
"defaultNavUrl": "/plugins/phlare/",
|
||||
"category": "profiling",
|
||||
"state": "",
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
},
|
||||
{
|
||||
"name": "Graph (old)",
|
||||
"type": "panel",
|
||||
@@ -1137,7 +1178,8 @@
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
},{
|
||||
},
|
||||
{
|
||||
"name": "Parca",
|
||||
"type": "datasource",
|
||||
"id": "parca",
|
||||
@@ -1149,10 +1191,12 @@
|
||||
"url": "https://www.grafana.com"
|
||||
},
|
||||
"description": "Continuous profiling for analysis of CPU and memory usage, down to the line number and throughout time. Saving infrastructure cost, improving performance, and increasing reliability.",
|
||||
"links": [{
|
||||
"name": "GitHub Project",
|
||||
"url": "https://github.com/parca-dev/parca"
|
||||
}],
|
||||
"links": [
|
||||
{
|
||||
"name": "GitHub Project",
|
||||
"url": "https://github.com/parca-dev/parca"
|
||||
}
|
||||
],
|
||||
"logos": {
|
||||
"small": "public/app/plugins/datasource/parca/img/logo-small.svg",
|
||||
"large": "public/app/plugins/datasource/parca/img/logo-small.svg"
|
||||
@@ -1176,45 +1220,6 @@
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
},
|
||||
{
|
||||
"name": "Phlare",
|
||||
"type": "datasource",
|
||||
"id": "phlare",
|
||||
"enabled": true,
|
||||
"pinned": false,
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://www.grafana.com"
|
||||
},
|
||||
"description": "Horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation system. OSS profiling solution from Grafana Labs.",
|
||||
"links": [{
|
||||
"name": "GitHub Project",
|
||||
"url": "https://github.com/grafana/phlare"
|
||||
}],
|
||||
"logos": {
|
||||
"small": "public/app/plugins/datasource/phlare/img/phlare_icon_color.svg",
|
||||
"large": "public/app/plugins/datasource/phlare/img/phlare_icon_color.svg"
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "",
|
||||
"updated": ""
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
"defaultNavUrl": "/plugins/phlare/",
|
||||
"category": "profiling",
|
||||
"state": "",
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
},
|
||||
{
|
||||
"name": "Pie chart",
|
||||
"type": "panel",
|
||||
@@ -1806,4 +1811,4 @@
|
||||
"signatureType": "",
|
||||
"signatureOrg": ""
|
||||
}
|
||||
]
|
||||
]
|
||||
66
pkg/tsdb/phlare/client.go
Normal file
66
pkg/tsdb/phlare/client.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package phlare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ProfilingClient interface {
|
||||
ProfileTypes(context.Context) ([]*ProfileType, error)
|
||||
LabelNames(ctx context.Context, query string, start int64, end int64) ([]string, error)
|
||||
LabelValues(ctx context.Context, query string, label string, start int64, end int64) ([]string, error)
|
||||
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64) (*SeriesResponse, error)
|
||||
GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64) (*ProfileResponse, error)
|
||||
}
|
||||
|
||||
type ProfileType struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func getClient(backendType string, httpClient *http.Client, url string) ProfilingClient {
|
||||
if backendType == "pyroscope" {
|
||||
return NewPyroscopeClient(httpClient, url)
|
||||
}
|
||||
|
||||
// We treat unset value as phlare
|
||||
return NewPhlareClient(httpClient, url)
|
||||
}
|
||||
|
||||
type Flamebearer struct {
|
||||
Names []string
|
||||
Levels []*Level
|
||||
Total int64
|
||||
MaxSelf int64
|
||||
}
|
||||
|
||||
type Level struct {
|
||||
Values []int64
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
Labels []*LabelPair
|
||||
Points []*Point
|
||||
}
|
||||
|
||||
type LabelPair struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Value float64
|
||||
// Milliseconds unix timestamp
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Flamebearer *Flamebearer
|
||||
Units string
|
||||
}
|
||||
|
||||
type SeriesResponse struct {
|
||||
Series []*Series
|
||||
Units string
|
||||
Label string
|
||||
}
|
||||
@@ -3,18 +3,16 @@ package phlare
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana/pkg/infra/httpclient"
|
||||
querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
|
||||
"github.com/grafana/phlare/api/gen/proto/go/querier/v1/querierv1connect"
|
||||
typesv1 "github.com/grafana/phlare/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -26,7 +24,13 @@ var (
|
||||
|
||||
// PhlareDatasource is a datasource for querying application performance profiles.
|
||||
type PhlareDatasource struct {
|
||||
client querierv1connect.QuerierServiceClient
|
||||
httpClient *http.Client
|
||||
client ProfilingClient
|
||||
settings backend.DataSourceInstanceSettings
|
||||
}
|
||||
|
||||
type JsonData struct {
|
||||
BackendType string `json:"backendType"`
|
||||
}
|
||||
|
||||
// NewPhlareDatasource creates a new datasource instance.
|
||||
@@ -40,93 +44,152 @@ func NewPhlareDatasource(httpClientProvider httpclient.Provider, settings backen
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var jsonData *JsonData
|
||||
err = json.Unmarshal(settings.JSONData, &jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PhlareDatasource{
|
||||
client: querierv1connect.NewQuerierServiceClient(httpClient, settings.URL),
|
||||
httpClient: httpClient,
|
||||
client: getClient(jsonData.BackendType, httpClient, settings.URL),
|
||||
settings: settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
logger.Debug("CallResource", "Path", req.Path, "Method", req.Method, "Body", req.Body)
|
||||
if req.Path == "profileTypes" {
|
||||
return d.callProfileTypes(ctx, req, sender)
|
||||
return d.profileTypes(ctx, req, sender)
|
||||
}
|
||||
if req.Path == "labelNames" {
|
||||
return d.callLabelNames(ctx, req, sender)
|
||||
return d.labelNames(ctx, req, sender)
|
||||
}
|
||||
if req.Path == "series" {
|
||||
return d.callSeries(ctx, req, sender)
|
||||
if req.Path == "labelValues" {
|
||||
return d.labelValues(ctx, req, sender)
|
||||
}
|
||||
if req.Path == "backendType" {
|
||||
return d.backendType(ctx, req, sender)
|
||||
}
|
||||
return sender.Send(&backend.CallResourceResponse{
|
||||
Status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) callProfileTypes(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
res, err := d.client.ProfileTypes(ctx, connect.NewRequest(&querierv1.ProfileTypesRequest{}))
|
||||
func (d *PhlareDatasource) profileTypes(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
types, err := d.client.ProfileTypes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data []byte
|
||||
if res.Msg.ProfileTypes == nil {
|
||||
// Let's make sure we send at least empty array if we don't have any types
|
||||
data, err = json.Marshal([]*typesv1.ProfileType{})
|
||||
bodyData, err := json.Marshal(types)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: bodyData, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) labelNames(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := u.Query()
|
||||
start, err := strconv.ParseInt(query["start"][0], 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
end, err := strconv.ParseInt(query["end"][0], 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := d.client.LabelNames(ctx, query["query"][0], start, end)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error calling LabelNames: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: data, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type LabelValuesPayload struct {
|
||||
Query string
|
||||
Label string
|
||||
Start int64
|
||||
End int64
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) labelValues(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := u.Query()
|
||||
start, err := strconv.ParseInt(query["start"][0], 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
end, err := strconv.ParseInt(query["end"][0], 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := d.client.LabelValues(ctx, query["query"][0], query["label"][0], start, end)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error calling LabelValues: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: data, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BackendTypeRespBody struct {
|
||||
BackendType string `json:"backendType"` // "phlare" or "pyroscope"
|
||||
}
|
||||
|
||||
// backendType is a simplistic test to figure out if we are speaking to phlare or pyroscope backend
|
||||
func (d *PhlareDatasource) backendType(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := u.Query()
|
||||
body := &BackendTypeRespBody{BackendType: "unknown"}
|
||||
|
||||
pyroClient := getClient("pyroscope", d.httpClient, query["url"][0])
|
||||
_, err = pyroClient.ProfileTypes(ctx)
|
||||
|
||||
if err == nil {
|
||||
body.BackendType = "pyroscope"
|
||||
} else {
|
||||
data, err = json.Marshal(res.Msg.ProfileTypes)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: data, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SeriesRequestJson struct {
|
||||
Matchers []string `json:"matchers"`
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) callSeries(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
parsedUrl, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matchers, ok := parsedUrl.Query()["matchers"]
|
||||
if !ok {
|
||||
matchers = []string{"{}"}
|
||||
phlareClient := getClient("phlare", d.httpClient, d.settings.URL)
|
||||
_, err := phlareClient.ProfileTypes(ctx)
|
||||
if err == nil {
|
||||
body.BackendType = "phlare"
|
||||
}
|
||||
}
|
||||
|
||||
res, err := d.client.Series(ctx, connect.NewRequest(&querierv1.SeriesRequest{Matchers: matchers}))
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, val := range res.Msg.LabelsSet {
|
||||
withoutPrivate := withoutPrivateLabels(val.Labels)
|
||||
val.Labels = withoutPrivate
|
||||
}
|
||||
|
||||
data, err := json.Marshal(res.Msg.LabelsSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: data, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PhlareDatasource) callLabelNames(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
res, err := d.client.LabelNames(ctx, connect.NewRequest(&querierv1.LabelNamesRequest{}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(res.Msg.Names)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = sender.Send(&backend.CallResourceResponse{Body: data, Headers: req.Headers, Status: 200})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,7 +229,7 @@ func (d *PhlareDatasource) CheckHealth(ctx context.Context, _ *backend.CheckHeal
|
||||
status := backend.HealthStatusOk
|
||||
message := "Data source is working"
|
||||
|
||||
if _, err := d.client.ProfileTypes(ctx, connect.NewRequest(&querierv1.ProfileTypesRequest{})); err != nil {
|
||||
if _, err := d.client.ProfileTypes(ctx); err != nil {
|
||||
status = backend.HealthStatusError
|
||||
message = err.Error()
|
||||
}
|
||||
@@ -239,13 +302,3 @@ func (d *PhlareDatasource) PublishStream(_ context.Context, _ *backend.PublishSt
|
||||
Status: backend.PublishStreamStatusPermissionDenied,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func withoutPrivateLabels(labels []*typesv1.LabelPair) []*typesv1.LabelPair {
|
||||
res := make([]*typesv1.LabelPair, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
if !strings.HasPrefix(l.Name, "__") {
|
||||
res = append(res, l)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ func Test_CallResource(t *testing.T) {
|
||||
context.Background(),
|
||||
&backend.CallResourceRequest{
|
||||
PluginContext: backend.PluginContext{},
|
||||
Path: "series",
|
||||
Path: "profileTypes",
|
||||
Method: "GET",
|
||||
URL: "series?matchers=%7B%7D",
|
||||
URL: "profileTypes",
|
||||
Headers: nil,
|
||||
Body: nil,
|
||||
},
|
||||
@@ -50,7 +50,7 @@ func Test_CallResource(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, sender.Resp.Status)
|
||||
require.Equal(t, `[{"labels":[{"name":"instance","value":"127.0.0.1"},{"name":"job","value":"default"}]}]`, string(sender.Resp.Body))
|
||||
require.Equal(t, `[{"id":"type:1","label":"cpu"},{"id":"type:2","label":"memory"}]`, string(sender.Resp.Body))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ const (
|
||||
PhlareQueryTypeProfile PhlareQueryType = "profile"
|
||||
)
|
||||
|
||||
// PhlareDataQuery defines model for PhlareDataQuery.
|
||||
type PhlareDataQuery struct {
|
||||
// GrafanaPyroscopeDataQuery defines model for GrafanaPyroscopeDataQuery.
|
||||
type GrafanaPyroscopeDataQuery struct {
|
||||
// For mixed data sources the selected datasource is on the query level.
|
||||
// For non mixed scenarios this is undefined.
|
||||
// TODO find a better way to do this ^ that's friendly to schema
|
||||
|
||||
164
pkg/tsdb/phlare/phlareClient.go
Normal file
164
pkg/tsdb/phlare/phlareClient.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package phlare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
|
||||
"github.com/grafana/phlare/api/gen/proto/go/querier/v1/querierv1connect"
|
||||
)
|
||||
|
||||
type PhlareClient struct {
|
||||
connectClient querierv1connect.QuerierServiceClient
|
||||
}
|
||||
|
||||
func NewPhlareClient(httpClient *http.Client, url string) *PhlareClient {
|
||||
return &PhlareClient{
|
||||
connectClient: querierv1connect.NewQuerierServiceClient(httpClient, url),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PhlareClient) ProfileTypes(ctx context.Context) ([]*ProfileType, error) {
|
||||
res, err := c.connectClient.ProfileTypes(ctx, connect.NewRequest(&querierv1.ProfileTypesRequest{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Msg.ProfileTypes == nil {
|
||||
// Let's make sure we send at least empty array if we don't have any types
|
||||
return []*ProfileType{}, nil
|
||||
} else {
|
||||
pTypes := make([]*ProfileType, len(res.Msg.ProfileTypes))
|
||||
for i, pType := range res.Msg.ProfileTypes {
|
||||
pTypes[i] = &ProfileType{
|
||||
ID: pType.ID,
|
||||
Label: pType.Name + " - " + pType.SampleType,
|
||||
}
|
||||
}
|
||||
return pTypes, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PhlareClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64) (*SeriesResponse, error) {
|
||||
req := connect.NewRequest(&querierv1.SelectSeriesRequest{
|
||||
ProfileTypeID: profileTypeID,
|
||||
LabelSelector: labelSelector,
|
||||
Start: start,
|
||||
End: end,
|
||||
Step: step,
|
||||
GroupBy: groupBy,
|
||||
})
|
||||
|
||||
resp, err := c.connectClient.SelectSeries(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
series := make([]*Series, len(resp.Msg.Series))
|
||||
|
||||
for i, s := range resp.Msg.Series {
|
||||
labels := make([]*LabelPair, len(s.Labels))
|
||||
for i, l := range s.Labels {
|
||||
labels[i] = &LabelPair{
|
||||
Name: l.Name,
|
||||
Value: l.Value,
|
||||
}
|
||||
}
|
||||
|
||||
points := make([]*Point, len(s.Points))
|
||||
for i, p := range s.Points {
|
||||
points[i] = &Point{
|
||||
Value: p.Value,
|
||||
Timestamp: p.Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
series[i] = &Series{
|
||||
Labels: labels,
|
||||
Points: points,
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(profileTypeID, ":")
|
||||
|
||||
return &SeriesResponse{
|
||||
Series: series,
|
||||
Units: getUnits(profileTypeID),
|
||||
Label: parts[1],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PhlareClient) GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64) (*ProfileResponse, error) {
|
||||
req := &connect.Request[querierv1.SelectMergeStacktracesRequest]{
|
||||
Msg: &querierv1.SelectMergeStacktracesRequest{
|
||||
ProfileTypeID: profileTypeID,
|
||||
LabelSelector: labelSelector,
|
||||
Start: start,
|
||||
End: end,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.connectClient.SelectMergeStacktraces(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
levels := make([]*Level, len(resp.Msg.Flamegraph.Levels))
|
||||
for i, level := range resp.Msg.Flamegraph.Levels {
|
||||
levels[i] = &Level{
|
||||
Values: level.Values,
|
||||
}
|
||||
}
|
||||
|
||||
return &ProfileResponse{
|
||||
Flamebearer: &Flamebearer{
|
||||
Names: resp.Msg.Flamegraph.Names,
|
||||
Levels: levels,
|
||||
Total: resp.Msg.Flamegraph.Total,
|
||||
MaxSelf: resp.Msg.Flamegraph.MaxSelf,
|
||||
},
|
||||
Units: getUnits(profileTypeID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getUnits(profileTypeID string) string {
|
||||
parts := strings.Split(profileTypeID, ":")
|
||||
unit := parts[2]
|
||||
if unit == "nanoseconds" {
|
||||
return "ns"
|
||||
}
|
||||
if unit == "count" {
|
||||
return "short"
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
func (c *PhlareClient) LabelNames(ctx context.Context, query string, start int64, end int64) ([]string, error) {
|
||||
resp, err := c.connectClient.LabelNames(ctx, connect.NewRequest(&querierv1.LabelNamesRequest{}))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error seding LabelNames request %v", err)
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, label := range resp.Msg.Names {
|
||||
if !isPrivateLabel(label) {
|
||||
filtered = append(filtered, label)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (c *PhlareClient) LabelValues(ctx context.Context, query string, label string, start int64, end int64) ([]string, error) {
|
||||
resp, err := c.connectClient.LabelValues(ctx, connect.NewRequest(&querierv1.LabelValuesRequest{Name: label}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Msg.Names, nil
|
||||
}
|
||||
|
||||
func isPrivateLabel(label string) bool {
|
||||
return strings.HasPrefix(label, "__")
|
||||
}
|
||||
109
pkg/tsdb/phlare/phlareClient_test.go
Normal file
109
pkg/tsdb/phlare/phlareClient_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package phlare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
googlev1 "github.com/grafana/phlare/api/gen/proto/go/google/v1"
|
||||
querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/phlare/api/gen/proto/go/types/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_PhlareClient(t *testing.T) {
|
||||
connectClient := &FakePhlareConnectClient{}
|
||||
client := &PhlareClient{
|
||||
connectClient: connectClient,
|
||||
}
|
||||
|
||||
t.Run("GetSeries", func(t *testing.T) {
|
||||
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, 15)
|
||||
require.Nil(t, err)
|
||||
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "alloc_objects",
|
||||
}
|
||||
require.Equal(t, series, resp)
|
||||
})
|
||||
|
||||
t.Run("GetProfile", func(t *testing.T) {
|
||||
resp, err := client.GetProfile(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100)
|
||||
require.Nil(t, err)
|
||||
|
||||
series := &ProfileResponse{
|
||||
Flamebearer: &Flamebearer{
|
||||
Names: []string{"foo", "bar", "baz"},
|
||||
Levels: []*Level{
|
||||
{Values: []int64{0, 10, 0, 0}},
|
||||
{Values: []int64{0, 9, 0, 1}},
|
||||
{Values: []int64{0, 8, 8, 2}},
|
||||
},
|
||||
Total: 100,
|
||||
MaxSelf: 56,
|
||||
},
|
||||
Units: "short",
|
||||
}
|
||||
require.Equal(t, series, resp)
|
||||
})
|
||||
}
|
||||
|
||||
type FakePhlareConnectClient struct {
|
||||
Req interface{}
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) ProfileTypes(ctx context.Context, c *connect.Request[querierv1.ProfileTypesRequest]) (*connect.Response[querierv1.ProfileTypesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) LabelValues(ctx context.Context, c *connect.Request[querierv1.LabelValuesRequest]) (*connect.Response[querierv1.LabelValuesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) LabelNames(context.Context, *connect.Request[querierv1.LabelNamesRequest]) (*connect.Response[querierv1.LabelNamesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) Series(ctx context.Context, c *connect.Request[querierv1.SeriesRequest]) (*connect.Response[querierv1.SeriesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) SelectMergeStacktraces(ctx context.Context, c *connect.Request[querierv1.SelectMergeStacktracesRequest]) (*connect.Response[querierv1.SelectMergeStacktracesResponse], error) {
|
||||
f.Req = c
|
||||
return &connect.Response[querierv1.SelectMergeStacktracesResponse]{
|
||||
Msg: &querierv1.SelectMergeStacktracesResponse{
|
||||
Flamegraph: &querierv1.FlameGraph{
|
||||
Names: []string{"foo", "bar", "baz"},
|
||||
Levels: []*querierv1.Level{
|
||||
{Values: []int64{0, 10, 0, 0}},
|
||||
{Values: []int64{0, 9, 0, 1}},
|
||||
{Values: []int64{0, 8, 8, 2}},
|
||||
},
|
||||
Total: 100,
|
||||
MaxSelf: 56,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) {
|
||||
f.Req = req
|
||||
return &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{
|
||||
Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakePhlareConnectClient) SelectMergeProfile(ctx context.Context, c *connect.Request[querierv1.SelectMergeProfileRequest]) (*connect.Response[googlev1.Profile], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
279
pkg/tsdb/phlare/pyroscopeClient.go
Normal file
279
pkg/tsdb/phlare/pyroscopeClient.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package phlare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type PyroscopeClient struct {
|
||||
httpClient *http.Client
|
||||
URL string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func NewPyroscopeClient(httpClient *http.Client, url string) *PyroscopeClient {
|
||||
return &PyroscopeClient{
|
||||
httpClient: httpClient,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) ProfileTypes(ctx context.Context) ([]*ProfileType, error) {
|
||||
resp, err := c.httpClient.Get(c.URL + "/api/apps")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Error("failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apps []App
|
||||
|
||||
err = json.Unmarshal(body, &apps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var profileTypes []*ProfileType
|
||||
for _, app := range apps {
|
||||
profileTypes = append(profileTypes, &ProfileType{
|
||||
ID: app.Name,
|
||||
Label: app.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return profileTypes, nil
|
||||
}
|
||||
|
||||
type PyroscopeProfileResponse struct {
|
||||
Flamebearer *PyroFlamebearer `json:"flamebearer"`
|
||||
Metadata *Metadata `json:"metadata"`
|
||||
Groups map[string]*Group `json:"groups"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Units string `json:"units"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
StartTime int64 `json:"startTime"`
|
||||
Samples []int64 `json:"samples"`
|
||||
DurationDelta int64 `json:"durationDelta"`
|
||||
}
|
||||
|
||||
type PyroFlamebearer struct {
|
||||
Levels [][]int64 `json:"levels"`
|
||||
MaxSelf int64 `json:"maxSelf"`
|
||||
NumTicks int64 `json:"numTicks"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) getProfileData(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string) (*PyroscopeProfileResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Add("from", strconv.FormatInt(start, 10))
|
||||
params.Add("until", strconv.FormatInt(end, 10))
|
||||
params.Add("query", profileTypeID+labelSelector)
|
||||
params.Add("format", "json")
|
||||
if len(groupBy) > 0 {
|
||||
params.Add("groupBy", groupBy[0])
|
||||
}
|
||||
|
||||
url := c.URL + "/render?" + params.Encode()
|
||||
logger.Debug("calling /render", "url", url)
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error calling /render api: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Error("failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var respData *PyroscopeProfileResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &respData)
|
||||
if err != nil {
|
||||
logger.Debug("flamegraph data", "body", string(body))
|
||||
return nil, fmt.Errorf("error decoding flamegraph data: %v", err)
|
||||
}
|
||||
|
||||
return respData, nil
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64) (*ProfileResponse, error) {
|
||||
respData, err := c.getProfileData(ctx, profileTypeID, labelSelector, start, end, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mappedLevels := make([]*Level, len(respData.Flamebearer.Levels))
|
||||
for i, level := range respData.Flamebearer.Levels {
|
||||
mappedLevels[i] = &Level{
|
||||
Values: level,
|
||||
}
|
||||
}
|
||||
|
||||
units := "short"
|
||||
if respData.Metadata.Units == "bytes" {
|
||||
units = "bytes"
|
||||
}
|
||||
if respData.Metadata.Units == "samples" {
|
||||
units = "ms"
|
||||
}
|
||||
|
||||
return &ProfileResponse{
|
||||
Flamebearer: &Flamebearer{
|
||||
Names: respData.Flamebearer.Names,
|
||||
Levels: mappedLevels,
|
||||
Total: respData.Flamebearer.NumTicks,
|
||||
MaxSelf: respData.Flamebearer.MaxSelf,
|
||||
},
|
||||
Units: units,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64) (*SeriesResponse, error) {
|
||||
// This is super ineffective at the moment. We need 2 different APIs one for profile one for series (timeline) data
|
||||
// but Pyro returns all in single response. This currently does the simplest thing and calls the same API 2 times
|
||||
// and gets the part of the response it needs.
|
||||
respData, err := c.getProfileData(ctx, profileTypeID, labelSelector, start, end, groupBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stepMillis := int64(step * 1000)
|
||||
var series []*Series
|
||||
|
||||
if len(respData.Groups) == 1 {
|
||||
series = []*Series{processGroup(respData.Groups["*"], stepMillis, nil)}
|
||||
} else {
|
||||
for key, val := range respData.Groups {
|
||||
// If we have a group by, we don't want the * group
|
||||
if key != "*" {
|
||||
label := &LabelPair{
|
||||
Name: groupBy[0],
|
||||
Value: key,
|
||||
}
|
||||
|
||||
series = append(series, processGroup(val, stepMillis, label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &SeriesResponse{Series: series}, nil
|
||||
}
|
||||
|
||||
// processGroup turns group timeline data into the Series format. Pyro does not seem to have a way to define step, so we
|
||||
// always get the data in specific step, and we have to aggregate a bit into s step size we need.
|
||||
func processGroup(group *Group, step int64, label *LabelPair) *Series {
|
||||
series := &Series{}
|
||||
if label != nil {
|
||||
series.Labels = []*LabelPair{label}
|
||||
}
|
||||
|
||||
durationDeltaMillis := group.DurationDelta * 1000
|
||||
timestamp := group.StartTime * 1000
|
||||
value := int64(0)
|
||||
|
||||
for i, sample := range group.Samples {
|
||||
pointsLen := int64(len(series.Points))
|
||||
// Check if the timestamp of the sample is more than next timestamp in the series. If so we create a new point
|
||||
// with the value we have so far.
|
||||
if int64(i)*durationDeltaMillis > step*pointsLen+1 {
|
||||
series.Points = append(series.Points, &Point{
|
||||
Value: float64(value),
|
||||
Timestamp: timestamp + step*pointsLen,
|
||||
})
|
||||
value = 0
|
||||
}
|
||||
|
||||
value += sample
|
||||
}
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) LabelNames(ctx context.Context, query string, start int64, end int64) ([]string, error) {
|
||||
params := url.Values{}
|
||||
// Seems like this should be seconds instead of millis for other endpoints
|
||||
params.Add("from", strconv.FormatInt(start/1000, 10))
|
||||
params.Add("until", strconv.FormatInt(end/1000, 10))
|
||||
params.Add("query", query)
|
||||
resp, err := c.httpClient.Get(c.URL + "/labels?" + params.Encode())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Error("failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var names []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, label := range names {
|
||||
// Using the same func from Phlare client, works but should do separate one probably
|
||||
if !isPrivateLabel(label) {
|
||||
filtered = append(filtered, label)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) LabelValues(ctx context.Context, query string, label string, start int64, end int64) ([]string, error) {
|
||||
params := url.Values{}
|
||||
// Seems like this should be seconds instead of millis for other endpoints
|
||||
params.Add("from", strconv.FormatInt(start/1000, 10))
|
||||
params.Add("until", strconv.FormatInt(end/1000, 10))
|
||||
params.Add("label", label)
|
||||
params.Add("query", query)
|
||||
resp, err := c.httpClient.Get(c.URL + "/labels?" + params.Encode())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Error("failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
var values []string
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(body, &values)
|
||||
if err != nil {
|
||||
logger.Debug("response", "body", string(body))
|
||||
return nil, fmt.Errorf("error unmarshaling response %v", err)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
@@ -5,22 +5,19 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/live"
|
||||
"github.com/grafana/grafana/pkg/tsdb/phlare/kinds/dataquery"
|
||||
querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
|
||||
"github.com/xlab/treeprint"
|
||||
)
|
||||
|
||||
type queryModel struct {
|
||||
WithStreaming bool
|
||||
dataquery.PhlareDataQuery
|
||||
dataquery.GrafanaPyroscopeDataQuery
|
||||
}
|
||||
|
||||
type dsJsonModel struct {
|
||||
@@ -60,36 +57,34 @@ func (d *PhlareDatasource) query(ctx context.Context, pCtx backend.PluginContext
|
||||
logger.Debug("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep)
|
||||
}
|
||||
}
|
||||
req := connect.NewRequest(&querierv1.SelectSeriesRequest{
|
||||
ProfileTypeID: qm.ProfileTypeId,
|
||||
LabelSelector: qm.LabelSelector,
|
||||
Start: query.TimeRange.From.UnixMilli(),
|
||||
End: query.TimeRange.To.UnixMilli(),
|
||||
Step: math.Max(query.Interval.Seconds(), parsedInterval.Seconds()),
|
||||
GroupBy: qm.GroupBy,
|
||||
})
|
||||
|
||||
logger.Debug("Sending SelectSeriesRequest", "request", req, "queryModel", qm)
|
||||
seriesResp, err := d.client.SelectSeries(ctx, req)
|
||||
logger.Debug("Sending SelectSeriesRequest", "queryModel", qm)
|
||||
seriesResp, err := d.client.GetSeries(
|
||||
ctx,
|
||||
qm.ProfileTypeId,
|
||||
qm.LabelSelector,
|
||||
query.TimeRange.From.UnixMilli(),
|
||||
query.TimeRange.To.UnixMilli(),
|
||||
qm.GroupBy,
|
||||
math.Max(query.Interval.Seconds(), parsedInterval.Seconds()),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Querying SelectSeries()", "err", err)
|
||||
response.Error = err
|
||||
return response
|
||||
}
|
||||
// add the frames to the response.
|
||||
response.Frames = append(response.Frames, seriesToDataFrames(seriesResp, qm.ProfileTypeId)...)
|
||||
response.Frames = append(response.Frames, seriesToDataFrames(seriesResp)...)
|
||||
}
|
||||
|
||||
if query.QueryType == queryTypeProfile || query.QueryType == queryTypeBoth {
|
||||
req := makeRequest(qm, query)
|
||||
logger.Debug("Sending SelectMergeStacktracesRequest", "request", req, "queryModel", qm)
|
||||
resp, err := d.client.SelectMergeStacktraces(ctx, req)
|
||||
logger.Debug("Calling GetProfile", "queryModel", qm)
|
||||
prof, err := d.client.GetProfile(ctx, qm.ProfileTypeId, qm.LabelSelector, query.TimeRange.From.UnixMilli(), query.TimeRange.To.UnixMilli())
|
||||
if err != nil {
|
||||
logger.Error("Querying SelectMergeStacktraces()", "err", err)
|
||||
logger.Error("Error GetProfile()", "err", err)
|
||||
response.Error = err
|
||||
return response
|
||||
}
|
||||
frame := responseToDataFrames(resp.Msg, qm.ProfileTypeId)
|
||||
frame := responseToDataFrames(prof)
|
||||
response.Frames = append(response.Frames, frame)
|
||||
|
||||
// If query called with streaming on then return a channel
|
||||
@@ -108,23 +103,12 @@ func (d *PhlareDatasource) query(ctx context.Context, pCtx backend.PluginContext
|
||||
return response
|
||||
}
|
||||
|
||||
func makeRequest(qm queryModel, query backend.DataQuery) *connect.Request[querierv1.SelectMergeStacktracesRequest] {
|
||||
return &connect.Request[querierv1.SelectMergeStacktracesRequest]{
|
||||
Msg: &querierv1.SelectMergeStacktracesRequest{
|
||||
ProfileTypeID: qm.ProfileTypeId,
|
||||
LabelSelector: qm.LabelSelector,
|
||||
Start: query.TimeRange.From.UnixMilli(),
|
||||
End: query.TimeRange.To.UnixMilli(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// responseToDataFrames turns Phlare response to data.Frame. We encode the data into a nested set format where we have
|
||||
// [level, value, label] columns and by ordering the items in a depth first traversal order we can recreate the whole
|
||||
// tree back.
|
||||
func responseToDataFrames(resp *querierv1.SelectMergeStacktracesResponse, profileTypeID string) *data.Frame {
|
||||
tree := levelsToTree(resp.Flamegraph.Levels, resp.Flamegraph.Names)
|
||||
return treeToNestedSetDataFrame(tree, profileTypeID)
|
||||
func responseToDataFrames(resp *ProfileResponse) *data.Frame {
|
||||
tree := levelsToTree(resp.Flamebearer.Levels, resp.Flamebearer.Names)
|
||||
return treeToNestedSetDataFrame(tree, resp.Units)
|
||||
}
|
||||
|
||||
// START_OFFSET is offset of the bar relative to previous sibling
|
||||
@@ -153,7 +137,11 @@ type ProfileTree struct {
|
||||
|
||||
// levelsToTree converts flamebearer format into a tree. This is needed to then convert it into nested set format
|
||||
// dataframe. This should be temporary, and ideally we should get some sort of tree struct directly from Phlare API.
|
||||
func levelsToTree(levels []*querierv1.Level, names []string) *ProfileTree {
|
||||
func levelsToTree(levels []*Level, names []string) *ProfileTree {
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tree := &ProfileTree{
|
||||
Start: 0,
|
||||
Value: levels[0].Values[VALUE_OFFSET],
|
||||
@@ -278,7 +266,7 @@ type CustomMeta struct {
|
||||
// where ordering the items in depth first order and knowing the level/depth of each item we can recreate the
|
||||
// parent - child relationship without explicitly needing parent/child column, and we can later just iterate over the
|
||||
// dataFrame to again basically walking depth first over the tree/profile.
|
||||
func treeToNestedSetDataFrame(tree *ProfileTree, profileTypeID string) *data.Frame {
|
||||
func treeToNestedSetDataFrame(tree *ProfileTree, unit string) *data.Frame {
|
||||
frame := data.NewFrame("response")
|
||||
frame.Meta = &data.FrameMeta{PreferredVisualization: "flamegraph"}
|
||||
|
||||
@@ -287,9 +275,8 @@ func treeToNestedSetDataFrame(tree *ProfileTree, profileTypeID string) *data.Fra
|
||||
selfField := data.NewField("self", nil, []int64{})
|
||||
|
||||
// profileTypeID should encode the type of the profile with unit being the 3rd part
|
||||
parts := strings.Split(profileTypeID, ":")
|
||||
valueField.Config = &data.FieldConfig{Unit: normalizeUnit(parts[2])}
|
||||
selfField.Config = &data.FieldConfig{Unit: normalizeUnit(parts[2])}
|
||||
valueField.Config = &data.FieldConfig{Unit: unit}
|
||||
selfField.Config = &data.FieldConfig{Unit: unit}
|
||||
frame.Fields = data.Fields{levelField, valueField, selfField}
|
||||
|
||||
labelField := NewEnumField("label", nil)
|
||||
@@ -366,10 +353,10 @@ func walkTree(tree *ProfileTree, fn func(tree *ProfileTree)) {
|
||||
}
|
||||
}
|
||||
|
||||
func seriesToDataFrames(seriesResp *connect.Response[querierv1.SelectSeriesResponse], profileTypeID string) []*data.Frame {
|
||||
frames := make([]*data.Frame, 0, len(seriesResp.Msg.Series))
|
||||
func seriesToDataFrames(resp *SeriesResponse) []*data.Frame {
|
||||
frames := make([]*data.Frame, 0, len(resp.Series))
|
||||
|
||||
for _, series := range seriesResp.Msg.Series {
|
||||
for _, series := range resp.Series {
|
||||
// We create separate data frames as the series may not have the same length
|
||||
frame := data.NewFrame("series")
|
||||
frame.Meta = &data.FrameMeta{PreferredVisualization: "graph"}
|
||||
@@ -378,21 +365,13 @@ func seriesToDataFrames(seriesResp *connect.Response[querierv1.SelectSeriesRespo
|
||||
timeField := data.NewField("time", nil, []time.Time{})
|
||||
fields = append(fields, timeField)
|
||||
|
||||
label := ""
|
||||
unit := ""
|
||||
parts := strings.Split(profileTypeID, ":")
|
||||
if len(parts) == 5 {
|
||||
label = parts[1] // sample type e.g. cpu, goroutine, alloc_objects
|
||||
unit = normalizeUnit(parts[2])
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
for _, label := range series.Labels {
|
||||
labels[label.Name] = label.Value
|
||||
}
|
||||
|
||||
valueField := data.NewField(label, labels, []float64{})
|
||||
valueField.Config = &data.FieldConfig{Unit: unit}
|
||||
valueField := data.NewField(resp.Label, labels, []float64{})
|
||||
valueField.Config = &data.FieldConfig{Unit: resp.Units}
|
||||
|
||||
for _, point := range series.Points {
|
||||
timeField.Append(time.UnixMilli(point.Timestamp))
|
||||
@@ -405,13 +384,3 @@ func seriesToDataFrames(seriesResp *connect.Response[querierv1.SelectSeriesRespo
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func normalizeUnit(unit string) string {
|
||||
if unit == "nanoseconds" {
|
||||
return "ns"
|
||||
}
|
||||
if unit == "count" {
|
||||
return "short"
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
@@ -5,14 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bufbuild/connect-go"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
googlev1 "github.com/grafana/phlare/api/gen/proto/go/google/v1"
|
||||
querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/phlare/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
// This is where the tests for the datasource backend live.
|
||||
@@ -60,9 +55,9 @@ func Test_query(t *testing.T) {
|
||||
dataQuery.QueryType = queryTypeMetrics
|
||||
resp := ds.query(context.Background(), pCtx, *dataQuery)
|
||||
require.Nil(t, resp.Error)
|
||||
r, ok := client.Req.(*connect.Request[querierv1.SelectSeriesRequest])
|
||||
step, ok := client.Args[5].(float64)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(30), r.Msg.Step)
|
||||
require.Equal(t, float64(30), step)
|
||||
})
|
||||
|
||||
t.Run("query metrics uses default min step", func(t *testing.T) {
|
||||
@@ -75,9 +70,9 @@ func Test_query(t *testing.T) {
|
||||
}
|
||||
resp := ds.query(context.Background(), pCtxNoMinStep, *dataQuery)
|
||||
require.Nil(t, resp.Error)
|
||||
r, ok := client.Req.(*connect.Request[querierv1.SelectSeriesRequest])
|
||||
step, ok := client.Args[5].(float64)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(15), r.Msg.Step)
|
||||
require.Equal(t, float64(15), step)
|
||||
})
|
||||
|
||||
t.Run("query metrics uses group by", func(t *testing.T) {
|
||||
@@ -86,9 +81,9 @@ func Test_query(t *testing.T) {
|
||||
dataQuery.JSON = []byte(`{"profileTypeId":"memory:alloc_objects:count:space:bytes","labelSelector":"{app=\\\"baz\\\"}","groupBy":["app","instance"]}`)
|
||||
resp := ds.query(context.Background(), pCtx, *dataQuery)
|
||||
require.Nil(t, resp.Error)
|
||||
r, ok := client.Req.(*connect.Request[querierv1.SelectSeriesRequest])
|
||||
groupBy, ok := client.Args[4].([]string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, []string{"app", "instance"}, r.Msg.GroupBy)
|
||||
require.Equal(t, []string{"app", "instance"}, groupBy)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,20 +111,19 @@ func fieldValues[T any](field *data.Field) []T {
|
||||
|
||||
// This is where the tests for the datasource backend live.
|
||||
func Test_profileToDataFrame(t *testing.T) {
|
||||
resp := &connect.Response[querierv1.SelectMergeStacktracesResponse]{
|
||||
Msg: &querierv1.SelectMergeStacktracesResponse{
|
||||
Flamegraph: &querierv1.FlameGraph{
|
||||
Names: []string{"func1", "func2", "func3"},
|
||||
Levels: []*querierv1.Level{
|
||||
{Values: []int64{0, 20, 1, 2}},
|
||||
{Values: []int64{0, 10, 3, 1, 4, 5, 5, 2}},
|
||||
},
|
||||
Total: 987,
|
||||
MaxSelf: 123,
|
||||
profile := &ProfileResponse{
|
||||
Flamebearer: &Flamebearer{
|
||||
Names: []string{"func1", "func2", "func3"},
|
||||
Levels: []*Level{
|
||||
{Values: []int64{0, 20, 1, 2}},
|
||||
{Values: []int64{0, 10, 3, 1, 4, 5, 5, 2}},
|
||||
},
|
||||
Total: 987,
|
||||
MaxSelf: 123,
|
||||
},
|
||||
Units: "short",
|
||||
}
|
||||
frame := responseToDataFrames(resp.Msg, "memory:alloc_objects:count:space:bytes")
|
||||
frame := responseToDataFrames(profile)
|
||||
require.Equal(t, 4, len(frame.Fields))
|
||||
require.Equal(t, data.NewField("level", nil, []int64{0, 1, 1}), frame.Fields[0])
|
||||
require.Equal(t, data.NewField("value", nil, []int64{20, 10, 5}).SetConfig(&data.FieldConfig{Unit: "short"}), frame.Fields[1])
|
||||
@@ -142,7 +136,7 @@ func Test_profileToDataFrame(t *testing.T) {
|
||||
// This is where the tests for the datasource backend live.
|
||||
func Test_levelsToTree(t *testing.T) {
|
||||
t.Run("simple", func(t *testing.T) {
|
||||
levels := []*querierv1.Level{
|
||||
levels := []*Level{
|
||||
{Values: []int64{0, 100, 0, 0}},
|
||||
{Values: []int64{0, 40, 0, 1, 0, 30, 0, 2}},
|
||||
{Values: []int64{0, 15, 0, 3}},
|
||||
@@ -162,7 +156,7 @@ func Test_levelsToTree(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("medium", func(t *testing.T) {
|
||||
levels := []*querierv1.Level{
|
||||
levels := []*Level{
|
||||
{Values: []int64{0, 100, 0, 0}},
|
||||
{Values: []int64{0, 40, 0, 1, 0, 30, 0, 2, 0, 30, 0, 3}},
|
||||
{Values: []int64{0, 20, 0, 4, 50, 10, 0, 5}},
|
||||
@@ -200,7 +194,7 @@ func Test_treeToNestedDataFrame(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
frame := treeToNestedSetDataFrame(tree, "memory:alloc_objects:count:space:bytes")
|
||||
frame := treeToNestedSetDataFrame(tree, "short")
|
||||
|
||||
labelConfig := &data.FieldConfig{
|
||||
TypeConfig: &data.FieldTypeConfig{
|
||||
@@ -219,7 +213,7 @@ func Test_treeToNestedDataFrame(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("nil profile tree", func(t *testing.T) {
|
||||
frame := treeToNestedSetDataFrame(nil, "memory:alloc_objects:count:space:bytes")
|
||||
frame := treeToNestedSetDataFrame(nil, "short")
|
||||
require.Equal(t, 4, len(frame.Fields))
|
||||
require.Equal(t, 0, frame.Fields[0].Len())
|
||||
})
|
||||
@@ -227,40 +221,41 @@ func Test_treeToNestedDataFrame(t *testing.T) {
|
||||
|
||||
func Test_seriesToDataFrame(t *testing.T) {
|
||||
t.Run("single series", func(t *testing.T) {
|
||||
resp := &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{Labels: []*typesv1.LabelPair{}, Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "samples",
|
||||
}
|
||||
frames := seriesToDataFrames(resp, "process_cpu:samples:count:cpu:nanoseconds")
|
||||
frames := seriesToDataFrames(series)
|
||||
require.Equal(t, 2, len(frames[0].Fields))
|
||||
require.Equal(t, data.NewField("time", nil, []time.Time{time.UnixMilli(1000), time.UnixMilli(2000)}), frames[0].Fields[0])
|
||||
require.Equal(t, data.NewField("samples", map[string]string{}, []float64{30, 10}).SetConfig(&data.FieldConfig{Unit: "short"}), frames[0].Fields[1])
|
||||
|
||||
// with a label pair, the value field should name itself with a label pair name and not the profile type
|
||||
resp = &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{Labels: []*typesv1.LabelPair{{Name: "app", Value: "bar"}}, Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
series = &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{{Name: "app", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "samples",
|
||||
}
|
||||
frames = seriesToDataFrames(resp, "process_cpu:samples:count:cpu:nanoseconds")
|
||||
|
||||
frames = seriesToDataFrames(series)
|
||||
require.Equal(t, data.NewField("samples", map[string]string{"app": "bar"}, []float64{30, 10}).SetConfig(&data.FieldConfig{Unit: "short"}), frames[0].Fields[1])
|
||||
})
|
||||
|
||||
t.Run("single series", func(t *testing.T) {
|
||||
resp := &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}}, Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
{Labels: []*typesv1.LabelPair{{Name: "foo", Value: "baz"}}, Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
resp := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "baz"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "samples",
|
||||
}
|
||||
frames := seriesToDataFrames(resp, "process_cpu:samples:count:cpu:nanoseconds")
|
||||
frames := seriesToDataFrames(resp)
|
||||
require.Equal(t, 2, len(frames))
|
||||
require.Equal(t, 2, len(frames[0].Fields))
|
||||
require.Equal(t, 2, len(frames[1].Fields))
|
||||
@@ -270,100 +265,56 @@ func Test_seriesToDataFrame(t *testing.T) {
|
||||
}
|
||||
|
||||
type FakeClient struct {
|
||||
Req interface{}
|
||||
Args []interface{}
|
||||
}
|
||||
|
||||
func (f *FakeClient) ProfileTypes(ctx context.Context, c *connect.Request[querierv1.ProfileTypesRequest]) (*connect.Response[querierv1.ProfileTypesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakeClient) LabelValues(ctx context.Context, c *connect.Request[querierv1.LabelValuesRequest]) (*connect.Response[querierv1.LabelValuesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakeClient) LabelNames(context.Context, *connect.Request[querierv1.LabelNamesRequest]) (*connect.Response[querierv1.LabelNamesResponse], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakeClient) Series(ctx context.Context, c *connect.Request[querierv1.SeriesRequest]) (*connect.Response[querierv1.SeriesResponse], error) {
|
||||
return &connect.Response[querierv1.SeriesResponse]{
|
||||
Msg: &querierv1.SeriesResponse{
|
||||
LabelsSet: []*typesv1.Labels{{
|
||||
Labels: []*typesv1.LabelPair{
|
||||
{
|
||||
Name: "__unit__",
|
||||
Value: "cpu",
|
||||
},
|
||||
{
|
||||
Name: "instance",
|
||||
Value: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
Name: "job",
|
||||
Value: "default",
|
||||
},
|
||||
},
|
||||
}},
|
||||
func (f *FakeClient) ProfileTypes(ctx context.Context) ([]*ProfileType, error) {
|
||||
return []*ProfileType{
|
||||
{
|
||||
ID: "type:1",
|
||||
Label: "cpu",
|
||||
},
|
||||
{
|
||||
ID: "type:2",
|
||||
Label: "memory",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeClient) SelectMergeStacktraces(ctx context.Context, c *connect.Request[querierv1.SelectMergeStacktracesRequest]) (*connect.Response[querierv1.SelectMergeStacktracesResponse], error) {
|
||||
f.Req = c
|
||||
return &connect.Response[querierv1.SelectMergeStacktracesResponse]{
|
||||
Msg: &querierv1.SelectMergeStacktracesResponse{
|
||||
Flamegraph: &querierv1.FlameGraph{
|
||||
Names: []string{"foo", "bar", "baz"},
|
||||
Levels: []*querierv1.Level{
|
||||
{Values: []int64{0, 10, 0, 0}},
|
||||
{Values: []int64{0, 9, 0, 1}},
|
||||
{Values: []int64{0, 8, 8, 2}},
|
||||
},
|
||||
Total: 100,
|
||||
MaxSelf: 56,
|
||||
func (f *FakeClient) LabelValues(ctx context.Context, query string, label string, start int64, end int64) ([]string, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakeClient) LabelNames(ctx context.Context, query string, start int64, end int64) ([]string, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *FakeClient) GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64) (*ProfileResponse, error) {
|
||||
return &ProfileResponse{
|
||||
Flamebearer: &Flamebearer{
|
||||
Names: []string{"foo", "bar", "baz"},
|
||||
Levels: []*Level{
|
||||
{Values: []int64{0, 10, 0, 0}},
|
||||
{Values: []int64{0, 9, 0, 1}},
|
||||
{Values: []int64{0, 8, 8, 2}},
|
||||
},
|
||||
Total: 100,
|
||||
MaxSelf: 56,
|
||||
},
|
||||
Units: "count",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) {
|
||||
f.Req = req
|
||||
return &connect.Response[querierv1.SelectSeriesResponse]{
|
||||
Msg: &querierv1.SelectSeriesResponse{
|
||||
Series: []*typesv1.Series{
|
||||
{
|
||||
Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Points: []*typesv1.Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeClient) SelectMergeProfile(ctx context.Context, c *connect.Request[querierv1.SelectMergeProfileRequest]) (*connect.Response[googlev1.Profile], error) {
|
||||
f.Req = c
|
||||
p := &googlev1.Profile{
|
||||
SampleType: []*googlev1.ValueType{
|
||||
{Type: 1, Unit: 2},
|
||||
},
|
||||
Sample: []*googlev1.Sample{
|
||||
func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64) (*SeriesResponse, error) {
|
||||
f.Args = []interface{}{profileTypeID, labelSelector, start, end, groupBy, step}
|
||||
return &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{
|
||||
Value: []int64{1},
|
||||
LocationId: []uint64{
|
||||
1, 2,
|
||||
},
|
||||
Labels: []*LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}},
|
||||
},
|
||||
},
|
||||
Mapping: []*googlev1.Mapping{{Id: 1}},
|
||||
Location: []*googlev1.Location{
|
||||
{Id: 1, MappingId: 1, Line: []*googlev1.Line{{FunctionId: 1}}},
|
||||
{Id: 2, MappingId: 1, Line: []*googlev1.Line{{FunctionId: 2}}},
|
||||
},
|
||||
Function: []*googlev1.Function{
|
||||
{Id: 1, Name: 3},
|
||||
{Id: 2, Name: 4},
|
||||
},
|
||||
StringTable: []string{"", "cpu", "nanoseconds", "foo", "bar"},
|
||||
}
|
||||
return connect.NewResponse(p), nil
|
||||
Units: "count",
|
||||
Label: "test",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import React from 'react';
|
||||
import { useAsyncFn, useDebounce } from 'react-use';
|
||||
|
||||
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
|
||||
import { DataSourceHttpSettings, EventsWithValidation, LegacyForms, regexValidation } from '@grafana/ui';
|
||||
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Alert, DataSourceHttpSettings, EventsWithValidation, LegacyForms, regexValidation } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { PhlareDataSourceOptions } from './types';
|
||||
import { PhlareDataSource } from './datasource';
|
||||
import { BackendType, PhlareDataSourceOptions } from './types';
|
||||
|
||||
interface Props extends DataSourcePluginOptionsEditorProps<PhlareDataSourceOptions> {}
|
||||
|
||||
export const ConfigEditor = (props: Props) => {
|
||||
const { options, onOptionsChange } = props;
|
||||
const [mismatchedBackendType, setMismatchedBackendType] = React.useState<BackendType | undefined>();
|
||||
|
||||
const dataSourceSrv = getDataSourceSrv();
|
||||
|
||||
const [, getBackendType] = useAsyncFn(async () => {
|
||||
if (!options.url) {
|
||||
return;
|
||||
}
|
||||
const ds = await dataSourceSrv.get({ type: options.type, uid: options.uid });
|
||||
if (!(ds instanceof PhlareDataSource)) {
|
||||
// Should not happen, makes TS happy
|
||||
throw new Error('Datasource is not a PhlareDataSource');
|
||||
}
|
||||
|
||||
const { backendType } = await ds.getBackendType(options.url);
|
||||
if (backendType === 'unknown') {
|
||||
setMismatchedBackendType(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If user already has something selected don't overwrite but show warning.
|
||||
if (options.jsonData.backendType) {
|
||||
if (backendType !== options.jsonData.backendType) {
|
||||
setMismatchedBackendType(backendType);
|
||||
} else {
|
||||
setMismatchedBackendType(undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onOptionsChange({ ...options, jsonData: { ...options.jsonData, backendType } });
|
||||
}, [options]);
|
||||
|
||||
useDebounce(getBackendType, 500, [options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -57,7 +94,50 @@ export const ConfigEditor = (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<LegacyForms.FormField
|
||||
label="Backend type"
|
||||
labelWidth={13}
|
||||
inputEl={
|
||||
<LegacyForms.Select<BackendType>
|
||||
allowCustomValue={false}
|
||||
value={options.jsonData.backendType ? backendTypeOptions[options.jsonData.backendType] : undefined}
|
||||
options={Object.values(backendTypeOptions)}
|
||||
onChange={(option) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
...options.jsonData,
|
||||
backendType: option.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
tooltip="Select what type of backend you use. This datasource supports both Phlare and Pyroscope backends."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mismatchedBackendType && (
|
||||
<Alert
|
||||
title={`"${options.jsonData.backendType}" option is selected but it seems like you are using "${mismatchedBackendType}" backend.`}
|
||||
severity="warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const backendTypeOptions: Record<BackendType, SelectableValue<BackendType>> = {
|
||||
phlare: {
|
||||
label: 'Phlare',
|
||||
value: 'phlare',
|
||||
},
|
||||
pyroscope: {
|
||||
label: 'Pyroscope',
|
||||
value: 'pyroscope',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useLatest } from 'react-use';
|
||||
import { useAsync, useLatest } from 'react-use';
|
||||
|
||||
import { CodeEditor, Monaco, useStyles2, monacoTypes } from '@grafana/ui';
|
||||
|
||||
import { languageDefinition } from '../phlareql';
|
||||
import { SeriesMessage } from '../types';
|
||||
|
||||
import { CompletionProvider } from './autocomplete';
|
||||
|
||||
@@ -13,11 +12,12 @@ interface Props {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onRunQuery: (value: string) => void;
|
||||
series?: SeriesMessage;
|
||||
labels?: string[];
|
||||
getLabelValues: (label: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export function LabelsEditor(props: Props) {
|
||||
const setupAutocompleteFn = useAutocomplete(props.series);
|
||||
const setupAutocompleteFn = useAutocomplete(props.getLabelValues, props.labels);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onRunQueryRef = useLatest(props.onRunQuery);
|
||||
@@ -92,15 +92,17 @@ const EDITOR_HEIGHT_OFFSET = 2;
|
||||
/**
|
||||
* Hook that returns function that will set up monaco autocomplete for the label selector
|
||||
*/
|
||||
function useAutocomplete(series?: SeriesMessage) {
|
||||
const providerRef = useRef<CompletionProvider>(new CompletionProvider());
|
||||
function useAutocomplete(getLabelValues: (label: string) => Promise<string[]>, labels?: string[]) {
|
||||
const providerRef = useRef<CompletionProvider>();
|
||||
if (providerRef.current === undefined) {
|
||||
providerRef.current = new CompletionProvider();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (series) {
|
||||
// When we have the value we will pass it to the CompletionProvider
|
||||
providerRef.current.setSeries(series);
|
||||
useAsync(async () => {
|
||||
if (providerRef.current) {
|
||||
providerRef.current.init(labels || [], getLabelValues);
|
||||
}
|
||||
}, [series]);
|
||||
}, [labels, getLabelValues]);
|
||||
|
||||
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -112,11 +114,13 @@ function useAutocomplete(series?: SeriesMessage) {
|
||||
|
||||
// This should be run in monaco onEditorDidMount
|
||||
return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
providerRef.current.editor = editor;
|
||||
providerRef.current.monaco = monaco;
|
||||
if (providerRef.current) {
|
||||
providerRef.current.editor = editor;
|
||||
providerRef.current.monaco = monaco;
|
||||
|
||||
const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current);
|
||||
autocompleteDisposeFun.current = dispose;
|
||||
const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current);
|
||||
autocompleteDisposeFun.current = dispose;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,7 +142,7 @@ const getStyles = () => {
|
||||
return {
|
||||
queryField: css`
|
||||
flex: 1;
|
||||
// Not exactly sure but without this the editor doe not shrink after resizing (so you can make it bigger but not
|
||||
// Not exactly sure but without this the editor does not shrink after resizing (so you can make it bigger but not
|
||||
// smaller). At the same time this does not actually make the editor 100px because it has flex 1 so I assume
|
||||
// this should sort of act as a flex-basis (but flex-basis does not work for this). So yeah CSS magic.
|
||||
width: 100px;
|
||||
|
||||
@@ -76,20 +76,12 @@ function setup(options: { props: Partial<Props> } = { props: {} }) {
|
||||
|
||||
ds.getProfileTypes = jest.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'process_cpu',
|
||||
ID: 'process_cpu:cpu',
|
||||
period_type: 'day',
|
||||
period_unit: 's',
|
||||
sample_unit: 'ms',
|
||||
sample_type: 'cpu',
|
||||
label: 'process_cpu - cpu',
|
||||
id: 'process_cpu:cpu',
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
ID: 'memory:memory',
|
||||
period_type: 'day',
|
||||
period_unit: 's',
|
||||
sample_unit: 'ms',
|
||||
sample_type: 'memory',
|
||||
label: 'memory',
|
||||
id: 'memory:memory',
|
||||
},
|
||||
] as ProfileTypeMessage[]);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defaults } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { CoreApp, QueryEditorProps } from '@grafana/data';
|
||||
import { CoreApp, QueryEditorProps, TimeRange } from '@grafana/data';
|
||||
import { ButtonCascader, CascaderOption } from '@grafana/ui';
|
||||
|
||||
import { defaultPhlare, defaultPhlareQueryType, Phlare } from '../dataquery.gen';
|
||||
import { defaultGrafanaPyroscope, defaultPhlareQueryType, GrafanaPyroscope } from '../dataquery.gen';
|
||||
import { PhlareDataSource } from '../datasource';
|
||||
import { PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types';
|
||||
import { BackendType, PhlareDataSourceOptions, ProfileTypeMessage, Query } from '../types';
|
||||
|
||||
import { EditorRow } from './EditorRow';
|
||||
import { EditorRows } from './EditorRows';
|
||||
@@ -16,44 +16,32 @@ import { QueryOptions } from './QueryOptions';
|
||||
|
||||
export type Props = QueryEditorProps<PhlareDataSource, Query, PhlareDataSourceOptions>;
|
||||
|
||||
export const defaultQuery: Partial<Phlare> = {
|
||||
...defaultPhlare,
|
||||
export const defaultQuery: Partial<GrafanaPyroscope> = {
|
||||
...defaultGrafanaPyroscope,
|
||||
queryType: defaultPhlareQueryType,
|
||||
};
|
||||
|
||||
export function QueryEditor(props: Props) {
|
||||
const profileTypes = useProfileTypes(props.datasource);
|
||||
|
||||
function onProfileTypeChange(value: string[], selectedOptions: CascaderOption[]) {
|
||||
if (selectedOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = selectedOptions[selectedOptions.length - 1].value;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
throw new Error('id is not string');
|
||||
}
|
||||
|
||||
props.onChange({ ...props.query, profileTypeId: id });
|
||||
}
|
||||
|
||||
function onLabelSelectorChange(value: string) {
|
||||
props.onChange({ ...props.query, labelSelector: value });
|
||||
}
|
||||
let query = normalizeQuery(props.query, props.app);
|
||||
|
||||
function handleRunQuery(value: string) {
|
||||
props.onChange({ ...props.query, labelSelector: value });
|
||||
props.onRunQuery();
|
||||
}
|
||||
|
||||
const seriesResult = useAsync(() => {
|
||||
return props.datasource.getSeries();
|
||||
}, [props.datasource]);
|
||||
|
||||
const { profileTypes, onProfileTypeChange, selectedProfileName } = useProfileTypes(
|
||||
props.datasource,
|
||||
props.query,
|
||||
props.onChange,
|
||||
props.datasource.backendType
|
||||
);
|
||||
const { labels, getLabelValues, onLabelSelectorChange } = useLabels(
|
||||
props.range,
|
||||
props.datasource,
|
||||
props.query,
|
||||
props.onChange
|
||||
);
|
||||
const cascaderOptions = useCascaderOptions(profileTypes);
|
||||
const selectedProfileName = useProfileName(profileTypes, props.query.profileTypeId);
|
||||
let query = normalizeQuery(props.query, props.app);
|
||||
|
||||
return (
|
||||
<EditorRows>
|
||||
@@ -65,61 +53,144 @@ export function QueryEditor(props: Props) {
|
||||
value={query.labelSelector}
|
||||
onChange={onLabelSelectorChange}
|
||||
onRunQuery={handleRunQuery}
|
||||
series={seriesResult.value}
|
||||
labels={labels}
|
||||
getLabelValues={getLabelValues}
|
||||
/>
|
||||
</EditorRow>
|
||||
<EditorRow>
|
||||
<QueryOptions query={query} onQueryChange={props.onChange} app={props.app} series={seriesResult.value} />
|
||||
<QueryOptions query={query} onQueryChange={props.onChange} app={props.app} labels={labels} />
|
||||
</EditorRow>
|
||||
</EditorRows>
|
||||
);
|
||||
}
|
||||
|
||||
function useLabels(
|
||||
range: TimeRange | undefined,
|
||||
datasource: PhlareDataSource,
|
||||
query: Query,
|
||||
onChange: (value: Query) => void
|
||||
) {
|
||||
// Round to nearest 5 seconds. If the range is something like last 1h then every render the range values change slightly
|
||||
// and what ever has range as dependency is rerun. So this effectively debounces the queries.
|
||||
const unpreciseRange = {
|
||||
to: Math.ceil((range?.to.valueOf() || 0) / 5000) * 5000,
|
||||
from: Math.floor((range?.from.valueOf() || 0) / 5000) * 5000,
|
||||
};
|
||||
|
||||
const labelsResult = useAsync(() => {
|
||||
return datasource.getLabelNames(query.profileTypeId + query.labelSelector, unpreciseRange.from, unpreciseRange.to);
|
||||
}, [datasource, query.profileTypeId, query.labelSelector, unpreciseRange.to, unpreciseRange.from]);
|
||||
|
||||
// Create a function with range and query already baked in so we don't have to send those everywhere
|
||||
const getLabelValues = useCallback(
|
||||
(label: string) => {
|
||||
return datasource.getLabelValues(
|
||||
query.profileTypeId + query.labelSelector,
|
||||
label,
|
||||
unpreciseRange.from,
|
||||
unpreciseRange.to
|
||||
);
|
||||
},
|
||||
[query, datasource, unpreciseRange.to, unpreciseRange.from]
|
||||
);
|
||||
|
||||
const onLabelSelectorChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange({ ...query, labelSelector: value });
|
||||
},
|
||||
[onChange, query]
|
||||
);
|
||||
|
||||
return { labels: labelsResult.value, getLabelValues, onLabelSelectorChange };
|
||||
}
|
||||
|
||||
// Turn profileTypes into cascader options
|
||||
function useCascaderOptions(profileTypes: ProfileTypeMessage[]) {
|
||||
return useMemo(() => {
|
||||
let mainTypes = new Map<string, CascaderOption>();
|
||||
// Classify profile types by name then sample type.
|
||||
for (let profileType of profileTypes) {
|
||||
if (!mainTypes.has(profileType.name)) {
|
||||
mainTypes.set(profileType.name, {
|
||||
label: profileType.name,
|
||||
value: profileType.ID,
|
||||
let parts: string[];
|
||||
// Phlare uses : as delimiter while Pyro uses .
|
||||
if (profileType.id.indexOf(':') > -1) {
|
||||
parts = profileType.id.split(':');
|
||||
} else {
|
||||
parts = profileType.id.split('.');
|
||||
const last = parts.pop()!;
|
||||
parts = [parts.join('.'), last];
|
||||
}
|
||||
|
||||
const [name, type] = parts;
|
||||
|
||||
if (!mainTypes.has(name)) {
|
||||
mainTypes.set(name, {
|
||||
label: name,
|
||||
value: profileType.id,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
mainTypes.get(profileType.name)?.children?.push({
|
||||
label: profileType.sample_type,
|
||||
value: profileType.ID,
|
||||
mainTypes.get(name)?.children?.push({
|
||||
label: type,
|
||||
value: profileType.id,
|
||||
});
|
||||
}
|
||||
return Array.from(mainTypes.values());
|
||||
}, [profileTypes]);
|
||||
}
|
||||
|
||||
function useProfileTypes(datasource: PhlareDataSource) {
|
||||
function useProfileTypes(
|
||||
datasource: PhlareDataSource,
|
||||
query: Query,
|
||||
onChange: (value: Query) => void,
|
||||
backendType: BackendType = 'phlare'
|
||||
) {
|
||||
const [profileTypes, setProfileTypes] = useState<ProfileTypeMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const profileTypes = await datasource.getProfileTypes();
|
||||
setProfileTypes(profileTypes);
|
||||
})();
|
||||
}, [datasource]);
|
||||
return profileTypes;
|
||||
|
||||
const onProfileTypeChange = useCallback(
|
||||
(value: string[], selectedOptions: CascaderOption[]) => {
|
||||
if (selectedOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = selectedOptions[selectedOptions.length - 1].value;
|
||||
|
||||
// Probably cannot happen but makes TS happy
|
||||
if (typeof id !== 'string') {
|
||||
throw new Error('id is not string');
|
||||
}
|
||||
|
||||
onChange({ ...query, profileTypeId: id });
|
||||
},
|
||||
[onChange, query]
|
||||
);
|
||||
|
||||
const selectedProfileName = useProfileName(profileTypes, query.profileTypeId, backendType);
|
||||
|
||||
return { profileTypes, onProfileTypeChange, selectedProfileName };
|
||||
}
|
||||
|
||||
function useProfileName(profileTypes: ProfileTypeMessage[], profileTypeId: string) {
|
||||
function useProfileName(profileTypes: ProfileTypeMessage[], profileTypeId: string, backendType: BackendType) {
|
||||
return useMemo(() => {
|
||||
if (!profileTypes) {
|
||||
return 'Loading';
|
||||
}
|
||||
const profile = profileTypes.find((type) => type.ID === profileTypeId);
|
||||
const profile = profileTypes.find((type) => type.id === profileTypeId);
|
||||
if (!profile) {
|
||||
if (backendType === 'pyroscope') {
|
||||
return 'Select application';
|
||||
}
|
||||
return 'Select a profile type';
|
||||
}
|
||||
|
||||
return profile.name + ' - ' + profile.sample_type;
|
||||
}, [profileTypeId, profileTypes]);
|
||||
return profile.label;
|
||||
}, [profileTypeId, profileTypes, backendType]);
|
||||
}
|
||||
|
||||
export function normalizeQuery(query: Query, app?: CoreApp | string) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useToggle } from 'react-use';
|
||||
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Icon, useStyles2, RadioButtonGroup, MultiSelect } from '@grafana/ui';
|
||||
|
||||
import { Query, SeriesMessage } from '../types';
|
||||
import { Query } from '../types';
|
||||
|
||||
import { EditorField } from './EditorField';
|
||||
import { Stack } from './Stack';
|
||||
@@ -14,7 +14,7 @@ export interface Props {
|
||||
query: Query;
|
||||
onQueryChange: (query: Query) => void;
|
||||
app?: CoreApp;
|
||||
series?: SeriesMessage;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
const typeOptions: Array<{ value: Query['queryType']; label: string; description: string }> = [
|
||||
@@ -30,28 +30,19 @@ function getTypeOptions(app?: CoreApp) {
|
||||
return typeOptions.filter((option) => option.value !== 'both');
|
||||
}
|
||||
|
||||
function getGroupByOptions(series?: SeriesMessage) {
|
||||
let options: SelectableValue[] = [];
|
||||
if (series) {
|
||||
const labels = series.flatMap((val) => {
|
||||
return val.labels.map((l) => l.name);
|
||||
});
|
||||
options = Array.from(new Set(labels)).map((l) => ({
|
||||
label: l,
|
||||
value: l,
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base on QueryOptionGroup component from grafana/ui but that is not available yet.
|
||||
*/
|
||||
export function QueryOptions({ query, onQueryChange, app, series }: Props) {
|
||||
export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
const [isOpen, toggleOpen] = useToggle(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
const typeOptions = getTypeOptions(app);
|
||||
const groupByOptions = getGroupByOptions(series);
|
||||
const groupByOptions = labels
|
||||
? labels.map((l) => ({
|
||||
label: l,
|
||||
value: l,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Stack gap={0} direction="column">
|
||||
|
||||
@@ -1,56 +1,62 @@
|
||||
import { monacoTypes, Monaco } from '@grafana/ui';
|
||||
|
||||
import { SeriesMessage } from '../types';
|
||||
|
||||
import { CompletionProvider } from './autocomplete';
|
||||
|
||||
describe('CompletionProvider', () => {
|
||||
it('suggests labels', () => {
|
||||
it('suggests labels', async () => {
|
||||
const { provider, model } = setup('{}', 1, defaultLabels);
|
||||
const result = provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foo', insertText: 'foo' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests label names with quotes', () => {
|
||||
it('suggests label names with quotes', async () => {
|
||||
const { provider, model } = setup('{foo=}', 6, defaultLabels);
|
||||
const result = provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'bar', insertText: '"bar"' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests label names without quotes', () => {
|
||||
it('suggests label names without quotes', async () => {
|
||||
const { provider, model } = setup('{foo="}', 7, defaultLabels);
|
||||
const result = provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'bar', insertText: 'bar' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests nothing without labels', () => {
|
||||
it('suggests nothing without labels', async () => {
|
||||
const { provider, model } = setup('{foo="}', 7, []);
|
||||
const result = provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('suggests labels on empty input', () => {
|
||||
it('suggests labels on empty input', async () => {
|
||||
const { provider, model } = setup('', 0, defaultLabels);
|
||||
const result = provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
const result = await provider.provideCompletionItems(model, {} as monacoTypes.Position);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foo', insertText: '{foo="' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const defaultLabels = [{ labels: [{ name: 'foo', value: 'bar' }] }];
|
||||
const defaultLabels = ['foo'];
|
||||
|
||||
function setup(value: string, offset: number, series?: SeriesMessage) {
|
||||
function setup(value: string, offset: number, labels: string[] = []) {
|
||||
const provider = new CompletionProvider();
|
||||
if (series) {
|
||||
provider.setSeries(series);
|
||||
}
|
||||
provider.init(labels, (label) => {
|
||||
if (labels.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const val = { foo: 'bar' }[label];
|
||||
const result = [];
|
||||
if (val) {
|
||||
result.push(val);
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
});
|
||||
const model = makeModel(value, offset);
|
||||
provider.monaco = {
|
||||
Range: {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { monacoTypes, Monaco } from '@grafana/ui';
|
||||
|
||||
import { SeriesMessage } from '../types';
|
||||
|
||||
/**
|
||||
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
|
||||
* autocomplete system.
|
||||
@@ -16,7 +14,13 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
|
||||
monaco: Monaco | undefined;
|
||||
editor: monacoTypes.editor.IStandaloneCodeEditor | undefined;
|
||||
|
||||
private labels: { [label: string]: Set<string> } = {};
|
||||
private labels: string[] = [];
|
||||
private getLabelValues: (label: string) => Promise<string[]> = () => Promise.resolve([]);
|
||||
|
||||
init(labels: string[], getLabelValues: (label: string) => Promise<string[]>) {
|
||||
this.labels = labels;
|
||||
this.getLabelValues = getLabelValues;
|
||||
}
|
||||
|
||||
provideCompletionItems(
|
||||
model: monacoTypes.editor.ITextModel,
|
||||
@@ -35,39 +39,21 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
|
||||
|
||||
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
|
||||
const situation = getSituation(model.getValue(), offset);
|
||||
const completionItems = this.getCompletions(situation);
|
||||
|
||||
// monaco by-default alphabetically orders the items.
|
||||
// to stop it, we use a number-as-string sortkey,
|
||||
// so that monaco keeps the order we use
|
||||
const maxIndexDigits = completionItems.length.toString().length;
|
||||
const suggestions: monacoTypes.languages.CompletionItem[] = completionItems.map((item, index) => ({
|
||||
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
|
||||
label: item.label,
|
||||
insertText: item.insertText,
|
||||
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
|
||||
range,
|
||||
}));
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* We expect the data directly from the request and transform it here. We do some deduplication and turn them into
|
||||
* object for quicker search as we usually need either a list of label names or values or particular label.
|
||||
*/
|
||||
setSeries(series: SeriesMessage) {
|
||||
this.labels = series.reduce<{ [label: string]: Set<string> }>((acc, serie) => {
|
||||
const seriesLabels = serie.labels.reduce<{ [label: string]: Set<string> }>((acc, labelValue) => {
|
||||
acc[labelValue.name] = acc[labelValue.name] || new Set();
|
||||
acc[labelValue.name].add(labelValue.value);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const label of Object.keys(seriesLabels)) {
|
||||
acc[label] = new Set([...(acc[label] || []), ...seriesLabels[label]]);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
return this.getCompletions(situation).then((completionItems) => {
|
||||
// monaco by-default alphabetically orders the items.
|
||||
// to stop it, we use a number-as-string sortkey,
|
||||
// so that monaco keeps the order we use
|
||||
const maxIndexDigits = completionItems.length.toString().length;
|
||||
const suggestions: monacoTypes.languages.CompletionItem[] = completionItems.map((item, index) => ({
|
||||
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
|
||||
label: item.label,
|
||||
insertText: item.insertText,
|
||||
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
|
||||
range,
|
||||
}));
|
||||
return { suggestions };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,17 +61,14 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
|
||||
* @param situation
|
||||
* @private
|
||||
*/
|
||||
private getCompletions(situation: Situation): Completion[] {
|
||||
if (!Object.keys(this.labels).length) {
|
||||
return [];
|
||||
}
|
||||
private async getCompletions(situation: Situation): Promise<Completion[]> {
|
||||
switch (situation.type) {
|
||||
// Not really sure what would make sense to suggest in this case so just leave it
|
||||
case 'UNKNOWN': {
|
||||
return [];
|
||||
}
|
||||
case 'EMPTY': {
|
||||
return Object.keys(this.labels).map((key) => {
|
||||
return this.labels.map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
insertText: `{${key}="`,
|
||||
@@ -94,7 +77,7 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
|
||||
});
|
||||
}
|
||||
case 'IN_LABEL_NAME':
|
||||
return Object.keys(this.labels).map((key) => {
|
||||
return this.labels.map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
insertText: key,
|
||||
@@ -102,7 +85,8 @@ export class CompletionProvider implements monacoTypes.languages.CompletionItemP
|
||||
};
|
||||
});
|
||||
case 'IN_LABEL_VALUE':
|
||||
return Array.from(this.labels[situation.labelName].values()).map((key) => {
|
||||
let values = await this.getLabelValues(situation.labelName);
|
||||
return values.map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
insertText: situation.betweenQuotes ? key : `"${key}"`,
|
||||
|
||||
@@ -16,7 +16,7 @@ export type PhlareQueryType = ('metrics' | 'profile' | 'both');
|
||||
|
||||
export const defaultPhlareQueryType: PhlareQueryType = 'both';
|
||||
|
||||
export interface Phlare extends common.DataQuery {
|
||||
export interface GrafanaPyroscope extends common.DataQuery {
|
||||
/**
|
||||
* Allows to group the results.
|
||||
*/
|
||||
@@ -31,7 +31,7 @@ export interface Phlare extends common.DataQuery {
|
||||
profileTypeId: string;
|
||||
}
|
||||
|
||||
export const defaultPhlare: Partial<Phlare> = {
|
||||
export const defaultGrafanaPyroscope: Partial<GrafanaPyroscope> = {
|
||||
groupBy: [],
|
||||
labelSelector: '{}',
|
||||
};
|
||||
|
||||
@@ -13,14 +13,17 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run
|
||||
import { extractLabelMatchers, toPromLikeExpr } from '../prometheus/language_utils';
|
||||
|
||||
import { normalizeQuery } from './QueryEditor/QueryEditor';
|
||||
import { PhlareDataSourceOptions, Query, ProfileTypeMessage, SeriesMessage } from './types';
|
||||
import { PhlareDataSourceOptions, Query, ProfileTypeMessage, BackendType } from './types';
|
||||
|
||||
export class PhlareDataSource extends DataSourceWithBackend<Query, PhlareDataSourceOptions> {
|
||||
backendType: BackendType;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<PhlareDataSourceOptions>,
|
||||
private readonly templateSrv: TemplateSrv = getTemplateSrv()
|
||||
) {
|
||||
super(instanceSettings);
|
||||
this.backendType = instanceSettings.jsonData.backendType ?? 'phlare';
|
||||
}
|
||||
|
||||
query(request: DataQueryRequest<Query>): Observable<DataQueryResponse> {
|
||||
@@ -49,13 +52,17 @@ export class PhlareDataSource extends DataSourceWithBackend<Query, PhlareDataSou
|
||||
return await super.getResource('profileTypes');
|
||||
}
|
||||
|
||||
async getSeries(): Promise<SeriesMessage> {
|
||||
// For now, we send empty matcher to get all the series
|
||||
return await super.getResource('series', { matchers: ['{}'] });
|
||||
async getLabelNames(query: string, start: number, end: number): Promise<string[]> {
|
||||
return await super.getResource('labelNames', { query, start, end });
|
||||
}
|
||||
|
||||
async getLabelNames(): Promise<string[]> {
|
||||
return await super.getResource('labelNames');
|
||||
async getLabelValues(query: string, label: string, start: number, end: number): Promise<string[]> {
|
||||
return await super.getResource('labelValues', { label, query, start, end });
|
||||
}
|
||||
|
||||
// We need the URL here because it may not be saved on the backend yet when used from config page.
|
||||
async getBackendType(url: string): Promise<{ backendType: BackendType | 'unknown' }> {
|
||||
return await super.getResource('backendType', { url });
|
||||
}
|
||||
|
||||
applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query {
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 140.07 151.15"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-8);}.cls-3{fill:url(#linear-gradient-3);}.cls-4{fill:url(#linear-gradient-4);}.cls-5{fill:url(#linear-gradient-2);}.cls-6{fill:url(#linear-gradient-6);}.cls-7{fill:url(#linear-gradient-7);}.cls-8{fill:url(#linear-gradient-5);}</style><linearGradient id="linear-gradient" x1="556.29" y1="168.71" x2="674.41" y2="28.91" gradientTransform="translate(-556.16) skewX(-8)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffef00"/><stop offset="1" stop-color="#ed5a27"/></linearGradient><linearGradient id="linear-gradient-2" x1="524.45" y1="141.81" x2="642.57" y2="2.01" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="546.04" y1="160.05" x2="664.16" y2="20.25" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-4" x1="561.98" y1="173.52" x2="680.1" y2="33.71" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-5" x1="535.6" y1="121.34" x2="655.87" y2="118.41" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-6" x1="536.16" y1="144.33" x2="656.43" y2="141.41" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-7" x1="613.28" y1="33.92" x2="517.45" y2="-5.31" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-8" x1="613.28" y1="33.92" x2="517.45" y2="-5.31" xlink:href="#linear-gradient"/></defs><g id="Layer_1-2"><g><g><path class="cls-1" d="M126.18,82.3c6.34-7.66,10.91-16.78,12.86-26.62h-39.74c-2.18,5.73-7.94,10.01-14.09,10.01H13.33l-2.34,16.61H126.18Z"/><path class="cls-5" d="M42.55,13.99l6.66,2.7h81.13C122.18,6.5,109.49,.12,94.43,.12H29.32c2.23,6.34,6.94,11.32,13.23,13.87Z"/><path class="cls-3" d="M48.24,22.87l-7.32,2.66c-7.06,2.57-13.22,7.62-17.23,14.04H88.89c6.12,0,10.65,4.23,11.26,9.92h39.75c.8-9.84-1.22-18.96-5.43-26.62H48.24Z"/><path class="cls-4" d="M10.13,88.48l-2.11,15.01-.23,1.65H79.67c15.1,0,29.63-6.41,40.66-16.66H10.13Z"/><polygon class="cls-8" points="44.06 128.3 46.45 111.32 6.92 111.32 4.53 128.3 44.06 128.3"/><polygon class="cls-6" points="3.66 134.48 1.32 151.15 40.85 151.15 43.19 134.48 3.66 134.48"/></g><g><path class="cls-7" d="M39.52,19.76C30.96,16.29,24.74,9.05,22.54,0,17.8,9.05,9.53,16.29,0,19.76c8.56,3.47,14.78,10.72,16.98,19.76,4.74-9.05,13-16.29,22.54-19.76Z"/><path class="cls-2" d="M22.54,0c2.2,9.05,8.43,16.29,16.98,19.76-9.53,3.47-17.8,10.72-22.54,19.76C14.78,30.48,8.56,23.23,0,19.76,9.53,16.29,17.8,9.05,22.54,0"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Phlare",
|
||||
"name": "Grafana Pyroscope",
|
||||
"id": "phlare",
|
||||
"category": "profiling",
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
"backend": true,
|
||||
|
||||
"info": {
|
||||
"description": "Horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation system. OSS profiling solution from Grafana Labs.",
|
||||
"description": "Supports Phlare and Pyroscope backends, horizontally-scalable, highly-available, multi-tenant continuous profiling aggregation systems.",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://www.grafana.com"
|
||||
},
|
||||
"keywords": ["grafana", "datasource", "phlare", "flamegraph"],
|
||||
"keywords": ["grafana", "datasource", "phlare", "flamegraph", "profiling", "continuous profiling", "pyroscope"],
|
||||
"logos": {
|
||||
"small": "img/phlare_icon_color.svg",
|
||||
"large": "img/phlare_icon_color.svg"
|
||||
"small": "img/grafana_pyroscope_icon.svg",
|
||||
"large": "img/grafana_pyroscope_icon.svg"
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { DataSourceJsonData } from '@grafana/data';
|
||||
|
||||
import { Phlare as PhlareBase, PhlareQueryType } from './dataquery.gen';
|
||||
import { GrafanaPyroscope, PhlareQueryType } from './dataquery.gen';
|
||||
|
||||
export interface Query extends PhlareBase {
|
||||
export interface Query extends GrafanaPyroscope {
|
||||
queryType: PhlareQueryType;
|
||||
}
|
||||
|
||||
export interface ProfileTypeMessage {
|
||||
ID: string;
|
||||
name: string;
|
||||
period_type: string;
|
||||
period_unit: string;
|
||||
sample_type: string;
|
||||
sample_unit: string;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type SeriesMessage = Array<{ labels: Array<{ name: string; value: string }> }>;
|
||||
|
||||
/**
|
||||
* These are options configured for each DataSource instance.
|
||||
*/
|
||||
export interface PhlareDataSourceOptions extends DataSourceJsonData {
|
||||
minStep?: string;
|
||||
backendType?: BackendType; // if not set we assume it's phlare
|
||||
}
|
||||
|
||||
export type BackendType = 'phlare' | 'pyroscope';
|
||||
|
||||
Reference in New Issue
Block a user