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
+6 -4
View File
@@ -82,8 +82,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0=
@@ -749,6 +749,8 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -887,8 +889,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc=
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
+3 -2
View File
@@ -7,7 +7,7 @@ require (
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling
cloud.google.com/go/kms v1.22.0 // @grafana/grafana-backend-group
cloud.google.com/go/storage v1.55.0 // @grafana/grafana-backend-group
connectrpc.com/connect v1.18.1 // @grafana/observability-traces-and-profiling
connectrpc.com/connect v1.19.1 // @grafana/observability-traces-and-profiling
cuelang.org/go v0.11.1 // @grafana/grafana-as-code
dario.cat/mergo v1.0.2 // @grafana/grafana-app-platform-squad
filippo.io/age v1.2.1 // @grafana/identity-access-team
@@ -111,7 +111,7 @@ require (
github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team
github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group
@@ -681,6 +681,7 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/google/gnostic v0.7.1 // indirect
github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
+6 -4
View File
@@ -627,8 +627,8 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS
cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M=
cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw=
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
@@ -1503,6 +1503,8 @@ github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PU
github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs=
github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -1685,8 +1687,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc=
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0=
+2
View File
@@ -755,6 +755,8 @@ github.com/felixge/fgprof v0.9.4/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZP
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw=
github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8=
github.com/flowstack/go-jsonschema v0.1.1 h1:dCrjGJRXIlbDsLAgTJZTjhwUJnnxVWl1OgNyYh5nyDc=
github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0=
github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c h1:yKN46XJHYC/gvgH2UsisJ31+n4K3S7QYZSfU2uAWjuI=
github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c/go.mod h1:L92h+dgwElEyUuShEwjbiHjseW410WIcNz+Bjutc8YQ=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
+4
View File
@@ -1263,4 +1263,8 @@ export interface FeatureToggles {
* Enables the creation of keepers that manage secrets stored on AWS secrets manager
*/
secretsManagementAppPlatformAwsKeeper?: boolean;
/**
* Enables profiles exemplars support in profiles drilldown
*/
profilesExemplars?: boolean;
}
@@ -25,6 +25,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
* Allows to group the results.
*/
groupBy: Array<string>;
/**
* If set to true, exemplars will be requested
*/
includeExemplars: boolean;
/**
* Specifies the query label selectors.
*/
@@ -49,6 +53,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
groupBy: [],
includeExemplars: false,
labelSelector: '{}',
spanSelector: [],
};
+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{
@@ -132,6 +132,7 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) {
type: profilesDataSourceSettings.type,
uid: profilesDataSourceSettings.uid,
},
includeExemplars: false,
},
],
};
@@ -33,6 +33,7 @@ describe('QueryEditor', () => {
refId: 'A',
maxNodes: 1000,
groupBy: [],
includeExemplars: false,
},
},
});
@@ -125,6 +126,7 @@ function setup(options: { props: Partial<Props> } = { props: {} }) {
maxNodes: 1000,
groupBy: [],
limit: 42,
includeExemplars: false,
}}
datasource={setupDs()}
onChange={onChange}
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import * as React from 'react';
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui';
import { Query } from '../types';
@@ -56,6 +57,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
if (query.maxNodes) {
collapsedInfo.push(`Max nodes: ${query.maxNodes}`);
}
if (query.includeExemplars) {
collapsedInfo.push(`With exemplars`);
}
return (
<Stack gap={0} direction="column">
@@ -142,6 +146,16 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
}}
/>
</EditorField>
{config.featureToggles.profilesExemplars && (
<EditorField label={'Exemplars'} tooltip={<>Include profile exemplars in the time series.</>}>
<InlineSwitch
value={query.includeExemplars || false}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
onQueryChange({ ...query, includeExemplars: event.currentTarget.checked });
}}
/>
</EditorField>
)}
</div>
</QueryOptionGroup>
</Stack>
@@ -42,8 +42,10 @@ composableKinds: DataQuery: {
// Sets the maximum number of nodes in the flamegraph.
maxNodes?: int64
#PyroscopeQueryType: "metrics" | "profile" | *"both" @cuetsy(kind="type")
// If set to true, the response will contain annotations
annotations?: bool
// If set to true, the response will contain annotations
annotations?: bool
// If set to true, exemplars will be requested
includeExemplars: bool | *false
}
}]
lenses: []
@@ -23,6 +23,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
* Allows to group the results.
*/
groupBy: Array<string>;
/**
* If set to true, exemplars will be requested
*/
includeExemplars: boolean;
/**
* Specifies the query label selectors.
*/
@@ -47,6 +51,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
groupBy: [],
includeExemplars: false,
labelSelector: '{}',
spanSelector: [],
};
@@ -43,6 +43,7 @@ describe('Pyroscope data source', () => {
queryType: 'both',
profileTypeId: '',
groupBy: [''],
includeExemplars: false,
},
]);
expect(queries).toMatchObject([
@@ -118,6 +119,7 @@ describe('normalizeQuery', () => {
queryType: 'metrics',
profileTypeId: 'cpu',
refId: '',
includeExemplars: false,
});
expect(normalized).toMatchObject({
labelSelector: '{app="myapp"}',
@@ -145,6 +147,7 @@ const defaultQuery = (query: Partial<Query>): Query => {
labelSelector: '',
profileTypeId: '',
queryType: defaultPyroscopeQueryType,
includeExemplars: false,
...query,
};
};
@@ -129,6 +129,7 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD
queryType: 'both',
profileTypeId: '',
groupBy: [],
includeExemplars: false,
};
}