Pyroscope: Exemplar support for series queries (#113926)

* feat(pyroscope): Exemplar support for series queries

use enum flag, add exemplar flag to explore

disable exemplars on explore as well

tests

feature toggle

fixing tests

* resolve conflicts

* lint
This commit is contained in:
Alberto
2026-01-07 13:25:42 +01:00
committed by GitHub
parent 0b9046be15
commit 6fee200327
25 changed files with 306 additions and 32 deletions
+7
View File
@@ -2090,6 +2090,13 @@ var (
FrontendOnly: false,
Owner: grafanaOperatorExperienceSquad,
},
{
Name: "profilesExemplars",
Description: "Enables profiles exemplars support in profiles drilldown",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: false,
},
}
)
+1
View File
@@ -283,3 +283,4 @@ useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true
multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
smoothingTransformation,experimental,@grafana/datapro,false,false,true
secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false
profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
283 multiPropsVariables experimental @grafana/dashboards-squad false false true
284 smoothingTransformation experimental @grafana/datapro false false true
285 secretsManagementAppPlatformAwsKeeper experimental @grafana/grafana-operator-experience-squad false false false
286 profilesExemplars experimental @grafana/observability-traces-and-profiling false false false
+4
View File
@@ -785,4 +785,8 @@ const (
// FlagSecretsManagementAppPlatformAwsKeeper
// Enables the creation of keepers that manage secrets stored on AWS secrets manager
FlagSecretsManagementAppPlatformAwsKeeper = "secretsManagementAppPlatformAwsKeeper"
// FlagProfilesExemplars
// Enables profiles exemplars support in profiles drilldown
FlagProfilesExemplars = "profilesExemplars"
)
+12
View File
@@ -2866,6 +2866,18 @@
"expression": "true"
}
},
{
"metadata": {
"name": "profilesExemplars",
"resourceVersion": "1767777507980",
"creationTimestamp": "2026-01-07T09:18:27Z"
},
"spec": {
"description": "Enables profiles exemplars support in profiles drilldown",
"stage": "experimental",
"codeowner": "@grafana/observability-traces-and-profiling"
}
},
{
"metadata": {
"name": "prometheusAzureOverrideAudience",
@@ -0,0 +1,43 @@
package exemplar
import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
type Exemplar struct {
Id string
Value float64
Timestamp int64
}
func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame {
frame := data.NewFrame("exemplar")
frame.Meta = &data.FrameMeta{
DataTopic: data.DataTopicAnnotations,
}
fields := []*data.Field{
data.NewField("Time", nil, []time.Time{}),
data.NewField("Value", labels, []float64{}), // add labels here?
data.NewField("Id", nil, []string{}),
}
fields[2].Config = &data.FieldConfig{
DisplayName: "Profile ID",
}
for name := range labels {
fields = append(fields, data.NewField(name, nil, []string{}))
}
frame.Fields = fields
for _, e := range exemplars {
frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id)
for name, value := range labels {
field, _ := frame.FieldByName(name)
if field != nil {
field.Append(value)
}
}
}
return frame
}
@@ -0,0 +1,34 @@
package exemplar
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCreateExemplarFrame(t *testing.T) {
exemplars := []*Exemplar{
{Id: "1", Value: 1.0, Timestamp: 100},
{Id: "2", Value: 2.0, Timestamp: 200},
}
labels := map[string]string{
"foo": "bar",
}
frame := CreateExemplarFrame(labels, exemplars)
require.Equal(t, "exemplar", frame.Name)
require.Equal(t, 4, len(frame.Fields))
require.Equal(t, "Time", frame.Fields[0].Name)
require.Equal(t, "Value", frame.Fields[1].Name)
require.Equal(t, "Id", frame.Fields[2].Name)
require.Equal(t, "foo", frame.Fields[3].Name)
rows, err := frame.RowLen()
require.NoError(t, err)
require.Equal(t, 2, rows)
row := frame.RowCopy(0)
require.Equal(t, 4, len(row))
require.Equal(t, 1.0, row[1])
require.Equal(t, "1", row[2])
require.Equal(t, "bar", row[3])
}
@@ -18,6 +18,8 @@ import (
"github.com/prometheus/prometheus/promql/parser"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
)
var (
@@ -31,7 +33,7 @@ type ProfilingClient interface {
ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error)
LabelNames(ctx context.Context, labelSelector string, start int64, end int64) ([]string, error)
LabelValues(ctx context.Context, label string, labelSelector string, start int64, end int64) ([]string, error)
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error)
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error)
GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
GetSpanProfile(ctx context.Context, profileTypeID string, labelSelector string, spanSelector []string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
}
@@ -32,6 +32,8 @@ type GrafanaPyroscopeDataQuery struct {
Limit *int64 `json:"limit,omitempty"`
// Sets the maximum number of nodes in the flamegraph.
MaxNodes *int64 `json:"maxNodes,omitempty"`
// If set to true, the response will contain annotations
Annotations *bool `json:"annotations,omitempty"`
// A unique identifier for the query within the list of targets.
// In server side expressions, the refId is used as a variable name to identify results.
// By default, the UI will assign A->Z; however setting meaningful names may be useful.
@@ -41,8 +43,8 @@ type GrafanaPyroscopeDataQuery struct {
// Specify the query flavor
// TODO make this required and give it a default
QueryType *string `json:"queryType,omitempty"`
// If set to true, the response will contain annotations
Annotations *bool `json:"annotations,omitempty"`
// If set to true, exemplars will be requested
IncludeExemplars bool `json:"includeExemplars"`
// 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
@@ -53,7 +55,8 @@ type GrafanaPyroscopeDataQuery struct {
// NewGrafanaPyroscopeDataQuery creates a new GrafanaPyroscopeDataQuery object.
func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery {
return &GrafanaPyroscopeDataQuery{
LabelSelector: "{}",
GroupBy: []string{},
LabelSelector: "{}",
GroupBy: []string{},
IncludeExemplars: false,
}
}
@@ -8,14 +8,16 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
"connectrpc.com/connect"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
)
type ProfileType struct {
@@ -49,6 +51,13 @@ type Point struct {
// Milliseconds unix timestamp
Timestamp int64
Annotations []*typesv1.ProfileAnnotation
Exemplars []*Exemplar
}
type Exemplar struct {
Id string
Value uint64
Timestamp int64
}
type ProfileResponse struct {
@@ -99,7 +108,7 @@ func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int
}
}
func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) {
func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) {
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetSeries", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector)))
defer span.End()
req := connect.NewRequest(&querierv1.SelectSeriesRequest{
@@ -110,6 +119,7 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
Step: step,
GroupBy: groupBy,
Limit: limit,
ExemplarType: exemplarType,
})
resp, err := c.connectClient.SelectSeries(ctx, req)
@@ -137,6 +147,16 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
Timestamp: p.Timestamp,
Annotations: p.Annotations,
}
if len(p.Exemplars) > 0 {
points[i].Exemplars = make([]*Exemplar, len(p.Exemplars))
for j, e := range p.Exemplars {
points[i].Exemplars[j] = &Exemplar{
Id: e.ProfileId,
Value: e.Value,
Timestamp: e.Timestamp,
}
}
}
}
series[i] = &Series{
@@ -5,10 +5,11 @@ import (
"testing"
"connectrpc.com/connect"
"github.com/stretchr/testify/require"
googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
"github.com/stretchr/testify/require"
)
func Test_PyroscopeClient(t *testing.T) {
@@ -19,7 +20,7 @@ func Test_PyroscopeClient(t *testing.T) {
t.Run("GetSeries", func(t *testing.T) {
limit := int64(42)
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15)
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_NONE)
require.Nil(t, err)
series := &SeriesResponse{
@@ -32,6 +33,21 @@ func Test_PyroscopeClient(t *testing.T) {
require.Equal(t, series, resp)
})
t.Run("GetSeriesWithExemplars", func(t *testing.T) {
limit := int64(42)
resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL)
require.Nil(t, err)
series := &SeriesResponse{
Series: []*Series{
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}},
},
Units: "short",
Label: "alloc_objects",
}
require.Equal(t, series, resp)
})
t.Run("GetProfile", func(t *testing.T) {
maxNodes := int64(-1)
resp, err := client.GetProfile(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, &maxNodes)
@@ -115,6 +131,21 @@ func (f *FakePyroscopeConnectClient) SelectMergeStacktraces(ctx context.Context,
func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) {
f.Req = req
if req.Msg.ExemplarType == typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL {
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, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(1000), Value: 3, ProfileId: "id1"}}},
{Timestamp: int64(2000), Value: 10, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(2000), Value: 1, ProfileId: "id2"}}},
},
},
},
},
}, nil
}
return &connect.Response[querierv1.SelectSeriesResponse]{
Msg: &querierv1.SelectSeriesResponse{
Series: []*typesv1.Series{
+33 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/live"
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/exemplar"
"github.com/xlab/treeprint"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
@@ -21,6 +22,8 @@ import (
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
)
type queryModel struct {
@@ -36,8 +39,12 @@ const (
queryTypeProfile = string(dataquery.PyroscopeQueryTypeProfile)
queryTypeMetrics = string(dataquery.PyroscopeQueryTypeMetrics)
queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth)
exemplarsFeatureToggle = "profilesExemplars"
)
var identityTransformation = func(value float64) float64 { return value }
// query processes single Pyroscope query transforming the response to data.Frame packaged in DataResponse
func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.query", trace.WithAttributes(attribute.String("query_type", query.QueryType)))
@@ -77,6 +84,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint())
}
}
exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE
if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) {
exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL
}
seriesResp, err := d.client.GetSeries(
gCtx,
profileTypeId,
@@ -86,6 +97,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
qm.GroupBy,
qm.Limit,
math.Max(query.Interval.Seconds(), parsedInterval.Seconds()),
exemplarType,
)
if err != nil {
span.RecordError(err)
@@ -475,6 +487,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
annotations := make([]*annotation.TimedAnnotation, 0)
for _, series := range resp.Series {
exemplars := make([]*exemplar.Exemplar, 0)
// We create separate data frames as the series may not have the same length
frame := data.NewFrame("series")
frameMeta := &data.FrameMeta{PreferredVisualization: "graph"}
@@ -516,14 +529,20 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
// Apply rate calculation for cumulative profiles
value := point.Value
transformation := identityTransformation
if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 {
value = value / stepDurationSec
transformation = func(value float64) float64 {
return value / stepDurationSec
}
// Convert CPU nanoseconds to cores
if isCPUTimeProfile(profileTypeID) {
value = value / 1e9
transformation = func(value float64) float64 {
return value / stepDurationSec / 1e9
}
}
}
value = transformation(value)
valueField.Append(value)
if withAnnotations {
for _, a := range point.Annotations {
@@ -533,10 +552,22 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
})
}
}
for _, e := range point.Exemplars {
exemplars = append(exemplars, &exemplar.Exemplar{
Id: e.Id,
Value: transformation(float64(e.Value)),
Timestamp: e.Timestamp,
})
}
}
frame.Fields = fields
frames = append(frames, frame)
if len(exemplars) > 0 {
frame := exemplar.CreateExemplarFrame(labels, exemplars)
frames = append(frames, frame)
}
}
if len(annotations) > 0 {
@@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
@@ -487,10 +488,21 @@ func Test_seriesToDataFrame(t *testing.T) {
require.Nil(t, frames[0].Meta.Custom)
})
t.Run("CPU time conversion to cores", func(t *testing.T) {
t.Run("CPU time conversion to cores with exemplars", func(t *testing.T) {
series := &SeriesResponse{
Series: []*Series{
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 3000000000}, {Timestamp: int64(2000), Value: 1500000000}}}, // 3s and 1.5s in nanoseconds
{
Labels: []*LabelPair{}, Points: []*Point{
{
Timestamp: int64(1000), Value: 3000000000, // 3s in nanoseconds
Exemplars: []*Exemplar{{Value: 300000000, Timestamp: 1000}}, // 0.3s in nanoseconds
},
{
Timestamp: int64(2000), Value: 1500000000, // 1.5s in nanoseconds
Exemplars: []*Exemplar{{Value: 150000000, Timestamp: 1000}}, // 0.15s in nanoseconds
},
},
},
},
Units: "ns",
Label: "cpu",
@@ -498,19 +510,32 @@ func Test_seriesToDataFrame(t *testing.T) {
// should convert nanoseconds to cores and set unit to "cores"
frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds")
require.NoError(t, err)
require.Equal(t, 1, len(frames))
require.Equal(t, 2, len(frames))
require.Equal(t, "cores", frames[0].Fields[1].Config.Unit)
// Check values were converted: 3000000000/15/1e9 = 0.2 cores/sec, 1500000000/15/1e9 = 0.1 cores/sec
values := fieldValues[float64](frames[0].Fields[1])
require.Equal(t, []float64{0.2, 0.1}, values)
// Check exemplar values were converted: 300000000/15/1e9 = 0.02 cores/sec, 150000000/15/1e9 = 0.01 cores/sec
exemplarValues := fieldValues[float64](frames[1].Fields[1])
require.Equal(t, []float64{0.02, 0.01}, exemplarValues)
})
t.Run("Memory allocation unit conversion to bytes/sec", func(t *testing.T) {
series := &SeriesResponse{
Series: []*Series{
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 150000000}, {Timestamp: int64(2000), Value: 300000000}}}, // 150 MB, 300 MB
{
Labels: []*LabelPair{}, Points: []*Point{
{
Timestamp: int64(1000), Value: 150000000, // 150 MB
Exemplars: []*Exemplar{{Value: 15000000, Timestamp: 1000}}, // 15 MB
}, {
Timestamp: int64(2000), Value: 300000000, // 300 MB
Exemplars: []*Exemplar{{Value: 30000000, Timestamp: 1000}}, // 30 MB
},
},
},
},
Units: "bytes",
Label: "memory_alloc",
@@ -518,19 +543,33 @@ func Test_seriesToDataFrame(t *testing.T) {
// should convert bytes to binBps and apply rate calculation
frames, err := seriesToDataFrames(series, false, 15.0, "memory:alloc_space:bytes:space:bytes")
require.NoError(t, err)
require.Equal(t, 1, len(frames))
require.Equal(t, 2, len(frames))
require.Equal(t, "binBps", frames[0].Fields[1].Config.Unit)
// Check values were rate calculated: 150000000/15 = 10000000, 300000000/15 = 20000000
values := fieldValues[float64](frames[0].Fields[1])
require.Equal(t, []float64{10000000, 20000000}, values)
// Check exemplar values were rate calculated: 15000000/15 = 1000000, 30000000/15 = 2000000
exemplarValues := fieldValues[float64](frames[1].Fields[1])
require.Equal(t, []float64{1000000, 2000000}, exemplarValues)
})
t.Run("Count-based profile unit conversion to ops/sec", func(t *testing.T) {
series := &SeriesResponse{
Series: []*Series{
{Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 1500}, {Timestamp: int64(2000), Value: 3000}}}, // 1500, 3000 contentions
{
Labels: []*LabelPair{}, Points: []*Point{
{
Timestamp: int64(1000), Value: 1500, // 1500 contentions
Exemplars: []*Exemplar{{Value: 150, Timestamp: 1000}}, // 150 contentions
}, {
Timestamp: int64(2000), Value: 3000, // 3000 contentions
Exemplars: []*Exemplar{{Value: 300, Timestamp: 1000}}, // 300 contentions
},
},
},
},
Units: "short",
Label: "contentions",
@@ -538,13 +577,16 @@ func Test_seriesToDataFrame(t *testing.T) {
// should convert short to ops and apply rate calculation
frames, err := seriesToDataFrames(series, false, 15.0, "mutex:contentions:count:contentions:count")
require.NoError(t, err)
require.Equal(t, 1, len(frames))
require.Equal(t, 2, len(frames))
require.Equal(t, "ops", frames[0].Fields[1].Config.Unit)
// Check values were rate calculated: 1500/15 = 100, 3000/15 = 200
values := fieldValues[float64](frames[0].Fields[1])
require.Equal(t, []float64{100, 200}, values)
// Check exemplar values were rate calculated: 150/15 = 10, 300/15 = 20
exemplarValues := fieldValues[float64](frames[1].Fields[1])
require.Equal(t, []float64{10, 20}, exemplarValues)
})
}
@@ -605,7 +647,7 @@ func (f *FakeClient) GetSpanProfile(ctx context.Context, profileTypeID, labelSel
}, nil
}
func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) {
func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) {
f.Args = []any{profileTypeID, labelSelector, start, end, groupBy, step}
return &SeriesResponse{
Series: []*Series{