From 7099cae39fd4a7a991cae237c43363d91055e481 Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Fri, 9 Jan 2026 09:05:48 +0000 Subject: [PATCH] WIP: Add heatmap to Pyroscope --- go.mod | 3 + go.sum | 4 +- go.work.sum | 1 + .../src/types/featureToggles.gen.ts | 4 + .../x/GrafanaPyroscopeDataQuery_types.gen.ts | 12 ++ pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 12 ++ .../heatmap/heatmap.go | 84 ++++++++ .../heatmap/heatmap_test.go | 192 ++++++++++++++++++ .../grafana-pyroscope-datasource/instance.go | 2 + .../kinds/dataquery/types_dataquery_gen.go | 17 +- .../pyroscopeClient.go | 73 +++++++ .../pyroscopeClient_test.go | 16 ++ .../grafana-pyroscope-datasource/query.go | 53 ++++- .../query_test.go | 15 ++ public/app/features/explore/Explore.test.tsx | 1 + public/app/features/explore/Explore.tsx | 30 +++ .../explore/ExploreQueryInspector.test.tsx | 1 + .../explore/Heatmap/HeatmapContainer.tsx | 70 +++++++ .../Heatmap/HeatmapExploreContainer.tsx | 80 ++++++++ .../SpanDetail/SpanFlameGraph.tsx | 2 + .../AddToDashboard/addToDashboard.test.ts | 1 + public/app/features/explore/mocks/data.ts | 1 + public/app/features/explore/state/query.ts | 2 + public/app/features/explore/state/utils.ts | 1 + .../features/explore/utils/decorators.test.ts | 1 + .../app/features/explore/utils/decorators.ts | 9 + .../QueryEditor/QueryEditor.test.tsx | 4 + .../QueryEditor/QueryOptions.tsx | 28 +++ .../dataquery.cue | 5 + .../dataquery.gen.ts | 12 ++ .../datasource.test.ts | 6 + .../datasource.ts | 2 + public/app/types/explore.ts | 2 + 36 files changed, 753 insertions(+), 5 deletions(-) create mode 100644 pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go create mode 100644 pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go create mode 100644 public/app/features/explore/Heatmap/HeatmapContainer.tsx create mode 100644 public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx diff --git a/go.mod b/go.mod index fe3e62e3fde..4dd1003bbe3 100644 --- a/go.mod +++ b/go.mod @@ -711,6 +711,9 @@ exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible // lock for mysql tsdb compat replace github.com/go-sql-driver/mysql => github.com/go-sql-driver/mysql v1.7.1 +// TODO: REMOVE THIS BEFORE MERGE +replace github.com/grafana/pyroscope/api => github.com/simonswine/pyroscope/api v0.0.0-20260105145211-3182b395db2f + // Use our fork of dolthub/go-mysql-server which makes non-cgo the default // since using a build tag is not sufficient for some use cases (e.g. developers tests in IDE). replace github.com/dolthub/go-mysql-server => github.com/grafana/go-mysql-server v0.20.1-grafana1 diff --git a/go.sum b/go.sum index 67d95c625b6..45af1906c6e 100644 --- a/go.sum +++ b/go.sum @@ -1685,8 +1685,6 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0= 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.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= @@ -2423,6 +2421,8 @@ github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd/go.mod h1:TrYk7fJV github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg= +github.com/simonswine/pyroscope/api v0.0.0-20260105145211-3182b395db2f h1:imf72hM/KA6Y9IeTxZS5loEMHotYPkV46UdYSfMgUx4= +github.com/simonswine/pyroscope/api v0.0.0-20260105145211-3182b395db2f/go.mod h1:ga4rxVfVsvUKEbmwx4/dryIRwHBYpuwP0mDB81aMR2Y= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/go.work.sum b/go.work.sum index d064248a16a..fd6e8ddfb09 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1488,6 +1488,7 @@ github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 h1:aSISeOcal5irEhJd1M+IrApc0PdcN7e7Aj4yuEnOrfQ= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/simonswine/pyroscope/api v0.0.0-20260105145211-3182b395db2f/go.mod h1:ga4rxVfVsvUKEbmwx4/dryIRwHBYpuwP0mDB81aMR2Y= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aa0581004e1..ba5d7408538 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1251,4 +1251,8 @@ export interface FeatureToggles { * Enables profiles exemplars support in profiles drilldown */ profilesExemplars?: boolean; + /** + * Enables heatmap visualization support for Pyroscope profiles + */ + profilesHeatmap?: boolean; } diff --git a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts index c297dc86114..e77ac1b7041 100644 --- a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts @@ -16,6 +16,8 @@ export type PyroscopeQueryType = ('metrics' | 'profile' | 'both'); export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both'; +export type HeatmapQueryType = ('individual' | 'span'); + export interface GrafanaPyroscopeDataQuery extends common.DataQuery { /** * If set to true, the response will contain annotations @@ -25,10 +27,18 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { * Allows to group the results. */ groupBy: Array; + /** + * Specifies the type of heatmap query + */ + heatmapType: (HeatmapQueryType | 'individual'); /** * If set to true, exemplars will be requested */ includeExemplars: boolean; + /** + * If set to true, heatmap data will be requested + */ + includeHeatmap: boolean; /** * Specifies the query label selectors. */ @@ -53,7 +63,9 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { export const defaultGrafanaPyroscopeDataQuery: Partial = { groupBy: [], + heatmapType: 'individual', includeExemplars: false, + includeHeatmap: false, labelSelector: '{}', spanSelector: [], }; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 4c0456e9457..8efc152353e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2069,6 +2069,13 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: false, }, + { + Name: "profilesHeatmap", + Description: "Enables heatmap visualization support for Pyroscope profiles", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityTracesAndProfilingSquad, + FrontendOnly: false, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index caba7cdab90..48d6cca4aa5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -280,3 +280,4 @@ 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 +profilesHeatmap,experimental,@grafana/observability-traces-and-profiling,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d68fa56ec8c..ae52ce7f24f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -789,4 +789,8 @@ const ( // FlagProfilesExemplars // Enables profiles exemplars support in profiles drilldown FlagProfilesExemplars = "profilesExemplars" + + // FlagProfilesHeatmap + // Enables heatmap visualization support for Pyroscope profiles + FlagProfilesHeatmap = "profilesHeatmap" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bfefc20f08b..ae826c374df 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2955,6 +2955,18 @@ "codeowner": "@grafana/observability-traces-and-profiling" } }, + { + "metadata": { + "name": "profilesHeatmap", + "resourceVersion": "1767703801452", + "creationTimestamp": "2026-01-06T12:50:01Z" + }, + "spec": { + "description": "Enables heatmap visualization support for Pyroscope profiles", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling" + } + }, { "metadata": { "name": "prometheusAzureOverrideAudience", diff --git a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go new file mode 100644 index 00000000000..e565722684d --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go @@ -0,0 +1,84 @@ +package heatmap + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +// Point represents a single heatmap point with timestamp, bucket minimums, and counts +type Point struct { + Timestamp int64 + YMin []float64 + Counts []int64 +} + +// CreateHeatmapFrame converts heatmap points to a DataFrame in HeatmapCells format +// This creates a sparse representation where each cell is explicitly defined by: +// - xMax: time value (timestamp) +// - yMin: bucket minimum value +// - yMax: bucket maximum value +// - count: number of matches in that bucket +// - yLayout: bucket layout (0 for linear buckets) +func CreateHeatmapFrame(labels map[string]string, points []*Point, units string) *data.Frame { + frame := data.NewFrame("heatmap") + frame.Meta = &data.FrameMeta{ + Type: "heatmap-cells", + } + + // Calculate total number of cells across all points + totalCells := 0 + for _, point := range points { + totalCells += len(point.Counts) + } + + // Pre-allocate slices for better performance + xMaxValues := make([]time.Time, 0, totalCells) + yMinValues := make([]float64, 0, totalCells) + yMaxValues := make([]float64, 0, totalCells) + countValues := make([]int64, 0, totalCells) + yLayoutValues := make([]int8, 0, totalCells) + + // Populate cells: for each time point, create a cell for each bucket + for _, point := range points { + timestamp := time.UnixMilli(point.Timestamp) + for i := 0; i < len(point.Counts); i++ { + xMaxValues = append(xMaxValues, timestamp) + yMinValues = append(yMinValues, point.YMin[i]) + + // Calculate yMax: for bucket i, yMax is yMin of bucket i+1 + // For the last bucket, use a large value or calculate based on bucket width + var yMax float64 + if i < len(point.YMin)-1 { + yMax = point.YMin[i+1] + } else { + // For the last bucket, calculate based on the previous bucket width + if i > 0 { + bucketWidth := point.YMin[i] - point.YMin[i-1] + yMax = point.YMin[i] + bucketWidth + } else { + // Single bucket case: use a reasonable default + yMax = point.YMin[i] * 2 + } + } + yMaxValues = append(yMaxValues, yMax) + countValues = append(countValues, point.Counts[i]) + yLayoutValues = append(yLayoutValues, 0) // 0 indicates linear bucket layout + } + } + + // Create data fields in the order expected by heatmap-cells format + frame.Fields = data.Fields{ + data.NewField("xMax", nil, xMaxValues), + data.NewField("yMin", nil, yMinValues).SetConfig(&data.FieldConfig{ + Unit: units, + }), + data.NewField("yMax", nil, yMaxValues).SetConfig(&data.FieldConfig{ + Unit: units, + }), + data.NewField("count", labels, countValues), + data.NewField("yLayout", nil, yLayoutValues), + } + + return frame +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go new file mode 100644 index 00000000000..6db92a95e1a --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go @@ -0,0 +1,192 @@ +package heatmap + +import ( + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" +) + +func TestCreateHeatmapFrame(t *testing.T) { + t.Run("creates frame with correct metadata", func(t *testing.T) { + points := []*Point{ + { + Timestamp: time.Now().UnixMilli(), + YMin: []float64{0, 100, 200}, + Counts: []int64{5, 10, 3}, + }, + } + labels := map[string]string{"service": "api"} + + frame := CreateHeatmapFrame(labels, points, "ns") + + require.NotNil(t, frame) + require.Equal(t, "heatmap", frame.Name) + require.NotNil(t, frame.Meta) + require.Equal(t, data.FrameType("heatmap-cells"), frame.Meta.Type) + }) + + t.Run("creates correct fields structure", func(t *testing.T) { + timestamp := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + points := []*Point{ + { + Timestamp: timestamp.UnixMilli(), + YMin: []float64{0, 100, 200}, + Counts: []int64{5, 10, 3}, + }, + } + + frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + + require.Len(t, frame.Fields, 5) + require.Equal(t, "xMax", frame.Fields[0].Name) + require.Equal(t, "yMin", frame.Fields[1].Name) + require.Equal(t, "yMax", frame.Fields[2].Name) + require.Equal(t, "count", frame.Fields[3].Name) + require.Equal(t, "yLayout", frame.Fields[4].Name) + }) + + t.Run("correctly expands multiple time points into cells", func(t *testing.T) { + timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + timestamp2 := time.Date(2024, 1, 1, 0, 1, 0, 0, time.UTC) + + points := []*Point{ + { + Timestamp: timestamp1.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{5, 10}, + }, + { + Timestamp: timestamp2.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{7, 12}, + }, + } + + frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + + // Should create 4 cells total (2 time points × 2 buckets) + require.Equal(t, 4, frame.Fields[0].Len()) + require.Equal(t, 4, frame.Fields[1].Len()) + require.Equal(t, 4, frame.Fields[2].Len()) + require.Equal(t, 4, frame.Fields[3].Len()) + require.Equal(t, 4, frame.Fields[4].Len()) + + // Check xMax values (timestamps) - compare Unix millis to avoid timezone issues + xMaxField := frame.Fields[0] + require.Equal(t, timestamp1.UnixMilli(), xMaxField.At(0).(time.Time).UnixMilli()) + require.Equal(t, timestamp1.UnixMilli(), xMaxField.At(1).(time.Time).UnixMilli()) + require.Equal(t, timestamp2.UnixMilli(), xMaxField.At(2).(time.Time).UnixMilli()) + require.Equal(t, timestamp2.UnixMilli(), xMaxField.At(3).(time.Time).UnixMilli()) + + // Check yMin values (bucket minimums) + yMinField := frame.Fields[1] + require.Equal(t, float64(0), yMinField.At(0)) + require.Equal(t, float64(100), yMinField.At(1)) + require.Equal(t, float64(0), yMinField.At(2)) + require.Equal(t, float64(100), yMinField.At(3)) + + // Check yMax values (bucket maximums) + yMaxField := frame.Fields[2] + require.Equal(t, float64(100), yMaxField.At(0)) // yMax for bucket [0-100) + require.Equal(t, float64(200), yMaxField.At(1)) // yMax for bucket [100-200) + require.Equal(t, float64(100), yMaxField.At(2)) // yMax for bucket [0-100) + require.Equal(t, float64(200), yMaxField.At(3)) // yMax for bucket [100-200) + + // Check count values + countField := frame.Fields[3] + require.Equal(t, int64(5), countField.At(0)) + require.Equal(t, int64(10), countField.At(1)) + require.Equal(t, int64(7), countField.At(2)) + require.Equal(t, int64(12), countField.At(3)) + + // Check yLayout values (should all be 0 for linear) + yLayoutField := frame.Fields[4] + require.Equal(t, int8(0), yLayoutField.At(0)) + require.Equal(t, int8(0), yLayoutField.At(1)) + require.Equal(t, int8(0), yLayoutField.At(2)) + require.Equal(t, int8(0), yLayoutField.At(3)) + }) + + t.Run("attaches labels to count field", func(t *testing.T) { + points := []*Point{ + { + Timestamp: time.Now().UnixMilli(), + YMin: []float64{0}, + Counts: []int64{5}, + }, + } + labels := map[string]string{"service": "api", "env": "prod"} + + frame := CreateHeatmapFrame(labels, points, "ns") + + countField := frame.Fields[3] + require.NotNil(t, countField.Labels) + require.Equal(t, "api", countField.Labels["service"]) + require.Equal(t, "prod", countField.Labels["env"]) + }) + + t.Run("sets unit on yMin and yMax fields", func(t *testing.T) { + points := []*Point{ + { + Timestamp: time.Now().UnixMilli(), + YMin: []float64{0}, + Counts: []int64{5}, + }, + } + + frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + + // yMin field should have units + yMinField := frame.Fields[1] + require.NotNil(t, yMinField.Config) + require.Equal(t, "ns", yMinField.Config.Unit) + + // yMax field should have units + yMaxField := frame.Fields[2] + require.NotNil(t, yMaxField.Config) + require.Equal(t, "ns", yMaxField.Config.Unit) + + // count field should NOT have units (or have empty unit) + countField := frame.Fields[3] + if countField.Config != nil { + require.Empty(t, countField.Config.Unit) + } + }) + + t.Run("handles empty points", func(t *testing.T) { + frame := CreateHeatmapFrame(map[string]string{}, []*Point{}, "ns") + + require.NotNil(t, frame) + require.Len(t, frame.Fields, 5) + require.Equal(t, 0, frame.Fields[0].Len()) + require.Equal(t, 0, frame.Fields[1].Len()) + require.Equal(t, 0, frame.Fields[2].Len()) + require.Equal(t, 0, frame.Fields[3].Len()) + require.Equal(t, 0, frame.Fields[4].Len()) + }) + + t.Run("handles varying bucket counts per time point", func(t *testing.T) { + timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + timestamp2 := time.Date(2024, 1, 1, 0, 1, 0, 0, time.UTC) + + points := []*Point{ + { + Timestamp: timestamp1.UnixMilli(), + YMin: []float64{0, 100, 200}, + Counts: []int64{5, 10, 3}, + }, + { + Timestamp: timestamp2.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{7, 12}, + }, + } + + frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + + // Should create 5 cells total (3 from first point + 2 from second) + require.Equal(t, 5, frame.Fields[0].Len()) + }) +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 0c2e7f29baa..0ee626e74c4 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -19,6 +19,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) @@ -36,6 +37,7 @@ type ProfilingClient interface { 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) + GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType) (*HeatmapResponse, error) } // PyroscopeDatasource is a datasource for querying application performance profiles. diff --git a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go index 7dabd28dc94..2fa1a6b8ec7 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go @@ -19,6 +19,13 @@ const ( PyroscopeQueryTypeBoth PyroscopeQueryType = "both" ) +type HeatmapQueryType string + +const ( + HeatmapQueryTypeIndividual HeatmapQueryType = "individual" + HeatmapQueryTypeSpan HeatmapQueryType = "span" +) + type GrafanaPyroscopeDataQuery struct { // Specifies the query label selectors. LabelSelector string `json:"labelSelector"` @@ -34,6 +41,12 @@ type GrafanaPyroscopeDataQuery struct { MaxNodes *int64 `json:"maxNodes,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"` + // If set to true, heatmap data will be requested + IncludeHeatmap bool `json:"includeHeatmap"` + // Specifies the type of heatmap query + HeatmapType string `json:"heatmapType"` // 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. @@ -43,8 +56,6 @@ 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, 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 @@ -58,5 +69,7 @@ func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery { LabelSelector: "{}", GroupBy: []string{}, IncludeExemplars: false, + IncludeHeatmap: false, + HeatmapType: "individual", } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index 1029b178f42..19571945abe 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -71,6 +71,22 @@ type SeriesResponse struct { Label string } +type HeatmapPoint struct { + Timestamp int64 + YMin []float64 + Counts []int64 +} + +type HeatmapSeries struct { + Labels []*LabelPair + Points []*HeatmapPoint +} + +type HeatmapResponse struct { + Series []*HeatmapSeries + Units string +} + type PyroscopeClient struct { connectClient querierv1connect.QuerierServiceClient } @@ -174,6 +190,63 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l }, nil } +func (c *PyroscopeClient) GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType) (*HeatmapResponse, error) { + ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetHeatmap", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector))) + defer span.End() + + req := connect.NewRequest(&querierv1.SelectHeatmapRequest{ + ProfileTypeID: profileTypeID, + LabelSelector: labelSelector, + Start: start, + End: end, + Step: step, + GroupBy: groupBy, + QueryType: queryType, + }) + + resp, err := c.connectClient.SelectHeatmap(ctx, req) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, backend.DownstreamErrorf("received error from client while getting heatmap: %w", err) + } + + series := make([]*HeatmapSeries, len(resp.Msg.Series)) + for i, s := range resp.Msg.Series { + labels := make([]*LabelPair, len(s.Labels)) + for j, l := range s.Labels { + labels[j] = &LabelPair{ + Name: l.Name, + Value: l.Value, + } + } + + points := make([]*HeatmapPoint, len(s.Slots)) + for j, slot := range s.Slots { + // Convert []int32 to []int64 + counts := make([]int64, len(slot.Counts)) + for k, c := range slot.Counts { + counts[k] = int64(c) + } + points[j] = &HeatmapPoint{ + Timestamp: slot.Timestamp, + YMin: slot.YMin, + Counts: counts, + } + } + + series[i] = &HeatmapSeries{ + Labels: labels, + Points: points, + } + } + + return &HeatmapResponse{ + Series: series, + Units: getUnits(profileTypeID), + }, nil +} + func (c *PyroscopeClient) GetProfile(ctx context.Context, profileTypeID, labelSelector string, start, end int64, maxNodes *int64) (*ProfileResponse, error) { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetProfile", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector))) defer span.End() diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go index c6bb80dcc2f..ee2e55e1f99 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go @@ -158,6 +158,22 @@ func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *conn }, nil } +func (f *FakePyroscopeConnectClient) SelectHeatmap(ctx context.Context, req *connect.Request[querierv1.SelectHeatmapRequest]) (*connect.Response[querierv1.SelectHeatmapResponse], error) { + f.Req = req + return &connect.Response[querierv1.SelectHeatmapResponse]{ + Msg: &querierv1.SelectHeatmapResponse{ + Series: []*typesv1.HeatmapSeries{ + { + Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}}, + Slots: []*typesv1.HeatmapSlot{ + {Timestamp: int64(1000), YMin: []float64{0, 100, 200}, Counts: []int32{5, 10, 3}}, + }, + }, + }, + }, + }, nil +} + func (f *FakePyroscopeConnectClient) SelectMergeProfile(ctx context.Context, c *connect.Request[querierv1.SelectMergeProfileRequest]) (*connect.Response[googlev1.Profile], error) { panic("implement me") } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index be662cef4fd..1af60b6508b 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -14,6 +14,7 @@ import ( "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/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/heatmap" "github.com/xlab/treeprint" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -23,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery" + querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) @@ -41,6 +43,7 @@ const ( queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth) exemplarsFeatureToggle = "profilesExemplars" + heatmapFeatureToggle = "profilesHeatmap" ) var identityTransformation = func(value float64) float64 { return value } @@ -84,6 +87,53 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint()) } } + + // Heatmap handling + if qm.IncludeHeatmap && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(heatmapFeatureToggle) { + heatmapType := querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_INDIVIDUAL + if qm.HeatmapType == "span" { + heatmapType = querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN + } + + heatmapResp, err := d.client.GetHeatmap( + gCtx, + profileTypeId, + labelSelector, + query.TimeRange.From.UnixMilli(), + query.TimeRange.To.UnixMilli(), + qm.GroupBy, + math.Max(query.Interval.Seconds(), parsedInterval.Seconds()), + heatmapType, + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logger.Error("Querying SelectHeatmap()", "err", err, "function", logEntrypoint()) + return err + } + + responseMutex.Lock() + defer responseMutex.Unlock() + for _, series := range heatmapResp.Series { + labels := make(map[string]string) + for _, label := range series.Labels { + labels[label.Name] = label.Value + } + // Convert HeatmapPoint to heatmap.Point + points := make([]*heatmap.Point, len(series.Points)) + for i, p := range series.Points { + points[i] = &heatmap.Point{ + Timestamp: p.Timestamp, + YMin: p.YMin, + Counts: p.Counts, + } + } + heatmapFrame := heatmap.CreateHeatmapFrame(labels, points, heatmapResp.Units) + response.Frames = append(response.Frames, heatmapFrame) + } + return nil + } + exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) { exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL @@ -107,6 +157,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont } // add the frames to the response. responseMutex.Lock() + defer responseMutex.Unlock() withAnnotations := qm.Annotations != nil && *qm.Annotations stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds()) frames, err := seriesToDataFrames(seriesResp, withAnnotations, stepDuration, profileTypeId) @@ -117,7 +168,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont return err } response.Frames = append(response.Frames, frames...) - responseMutex.Unlock() + return nil }) } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index a9a7cd41351..395aced4c6e 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" @@ -660,3 +661,17 @@ func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector Label: "test", }, nil } + +func (f *FakeClient) GetHeatmap(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType) (*HeatmapResponse, error) { + return &HeatmapResponse{ + Series: []*HeatmapSeries{ + { + Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, + Points: []*HeatmapPoint{ + {Timestamp: start, YMin: []float64{0, 100, 200}, Counts: []int64{5, 10, 3}}, + }, + }, + }, + Units: "nanoseconds", + }, nil +} diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 599adfdbb02..aed886a19f8 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -95,6 +95,7 @@ const dummyProps: Props = { showCustom: true, showNodeGraph: true, showFlameGraph: true, + showHeatmap: false, splitOpen: jest.fn(), splitted: false, eventBus: new EventBusSrv(), diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 9aa7bc1904b..dc0d4a90f24 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -45,6 +45,7 @@ import { CustomContainer } from './CustomContainer'; import { ExploreToolbar } from './ExploreToolbar'; import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer'; import { GraphContainer } from './Graph/GraphContainer'; +import { HeatmapContainer } from './Heatmap/HeatmapContainer'; import LogsContainer from './Logs/LogsContainer'; import { LogsSamplePanel } from './Logs/LogsSamplePanel'; import { NoData } from './NoData'; @@ -409,6 +410,26 @@ export class Explore extends PureComponent { ); } + renderHeatmapPanel(width: number) { + const { queryResponse, timeZone } = this.props; + + return ( + + + + ); + } + renderTablePanel(width: number) { const { exploreId, timeZone, eventBus } = this.props; return ( @@ -587,6 +608,7 @@ export class Explore extends PureComponent { showTrace, showCustom, showNodeGraph, + showHeatmap, showFlameGraph, showLogsSample, correlationEditorDetails, @@ -611,6 +633,7 @@ export class Explore extends PureComponent { queryResponse.rawPrometheusFrames, queryResponse.traceFrames, queryResponse.customFrames, + queryResponse.heatmapFrames, ].every((e) => e.length === 0); let correlationsBox = undefined; @@ -721,6 +744,11 @@ export class Explore extends PureComponent { {this.renderGraphPanel(width)} )} + {showHeatmap && ( + + {this.renderHeatmapPanel(width)} + + )} {showRawPrometheus && ( {this.renderRawPrometheus(width)} @@ -808,6 +836,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { queryResponse, showNodeGraph, showFlameGraph, + showHeatmap, showRawPrometheus, supplementaryQueries, correlationEditorHelperData, @@ -836,6 +865,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { showTrace, showCustom, showNodeGraph, + showHeatmap, showRawPrometheus, showFlameGraph, splitted: isSplit(state), diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index 5b085980883..34943ce1c27 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -62,6 +62,7 @@ const setup = (propOverrides = {}) => { customFrames: [], nodeGraphFrames: [], flameGraphFrames: [], + heatmapFrames: [], rawPrometheusFrames: [], graphResult: null, logsResult: null, diff --git a/public/app/features/explore/Heatmap/HeatmapContainer.tsx b/public/app/features/explore/Heatmap/HeatmapContainer.tsx new file mode 100644 index 00000000000..a61f047af6f --- /dev/null +++ b/public/app/features/explore/Heatmap/HeatmapContainer.tsx @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; + +import { + AbsoluteTimeRange, + DataFrame, + EventBus, + LoadingState, + SplitOpen, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { PanelChrome, PanelChromeProps } from '@grafana/ui'; + +import { HeatmapExploreContainer } from './HeatmapExploreContainer'; + +const MAX_NUMBER_OF_HEATMAPS = 5; + +interface Props extends Pick { + width: number; + height: number; + data: DataFrame[]; + eventBus: EventBus; + timeRange: TimeRange; + timeZone: TimeZone; + onChangeTime: (absoluteRange: AbsoluteTimeRange) => void; + splitOpenFn: SplitOpen; + loadingState: LoadingState; +} + +export const HeatmapContainer = ({ + data, + eventBus, + height, + width, + timeRange, + timeZone, + onChangeTime, + splitOpenFn, + loadingState, + statusMessage, +}: Props) => { + const slicedData = useMemo(() => { + return data.slice(0, MAX_NUMBER_OF_HEATMAPS); + }, [data]); + + return ( + + {(innerWidth, innerHeight) => ( + + )} + + ); +}; diff --git a/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx b/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx new file mode 100644 index 00000000000..f0bdf4df090 --- /dev/null +++ b/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; + +import { + AbsoluteTimeRange, + DataFrame, + DataLinksContext, + EventBus, + LoadingState, + SplitOpen, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { PanelRenderer } from '@grafana/runtime'; +import { TooltipDisplayMode } from '@grafana/schema'; + +import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor'; + +interface Props { + data: DataFrame[]; + height: number; + width: number; + timeRange: TimeRange; + timeZone: TimeZone; + loadingState: LoadingState; + splitOpenFn: SplitOpen; + onChangeTime?: (timeRange: AbsoluteTimeRange) => void; + eventBus: EventBus; +} + +export function HeatmapExploreContainer({ + data, + height, + width, + timeZone, + timeRange, + onChangeTime, + loadingState, + splitOpenFn, + eventBus, +}: Props) { + const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange); + + const panelOptions = useMemo( + () => ({ + calculate: false, // Data already in heatmap-cells format + color: { + scheme: 'Spectral', + steps: 64, + }, + tooltip: { + mode: TooltipDisplayMode.Single, + yHistogram: true, + showColorScale: true, + }, + legend: { + show: true, + }, + }), + [] + ); + + return ( + + + + ); +} diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx index fd525152a57..60a0011796e 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx @@ -133,6 +133,8 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) { uid: profilesDataSourceSettings.uid, }, includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual' as const, }, ], }; diff --git a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts index 2f42709c2b0..2d89b601ab4 100644 --- a/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts +++ b/public/app/features/explore/extensions/AddToDashboard/addToDashboard.test.ts @@ -100,6 +100,7 @@ function createEmptyQueryResponse(): ExplorePanelData { traceFrames: [], nodeGraphFrames: [], flameGraphFrames: [], + heatmapFrames: [], customFrames: [], tableFrames: [], rawPrometheusFrames: [], diff --git a/public/app/features/explore/mocks/data.ts b/public/app/features/explore/mocks/data.ts index 3cf601b623f..cdb5cf7f3a8 100644 --- a/public/app/features/explore/mocks/data.ts +++ b/public/app/features/explore/mocks/data.ts @@ -25,6 +25,7 @@ export const mockExplorePanelData = (props?: MockProps): Observable ({ traceFrames: [], nodeGraphFrames: [], flameGraphFrames: [], + heatmapFrames: [], customFrames: [], tableFrames: [], rawPrometheusFrames: [], diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 3be590a31f5..5f202e464ac 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -108,6 +108,7 @@ const createExplorePanelData = (args: Partial): ExplorePanelDa nodeGraphFrames: [], customFrames: [], flameGraphFrames: [], + heatmapFrames: [], rawPrometheusFrames: [], rawPrometheusResult: null, }; diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 5a877294649..490c0273b40 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -37,6 +37,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData const traceFrames: DataFrame[] = []; const nodeGraphFrames: DataFrame[] = []; const flameGraphFrames: DataFrame[] = []; + const heatmapFrames: DataFrame[] = []; const customFrames: DataFrame[] = []; for (const frame of data.series) { @@ -44,6 +45,13 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData customFrames.push(frame); continue; } + + // Check for heatmap-cells type BEFORE the switch statement + if (frame.meta?.type === 'heatmap-cells') { + heatmapFrames.push(frame); + continue; + } + switch (frame.meta?.preferredVisualisationType) { case 'logs': logsFrames.push(frame); @@ -87,6 +95,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData customFrames, flameGraphFrames, rawPrometheusFrames, + heatmapFrames, graphResult: null, tableResult: null, logsResult: null, diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index 212c55bbe5e..ca0cddfd7d4 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -34,6 +34,8 @@ describe('QueryEditor', () => { maxNodes: 1000, groupBy: [], includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual', }, }, }); @@ -127,6 +129,8 @@ function setup(options: { props: Partial } = { props: {} }) { groupBy: [], limit: 42, includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual', }} datasource={setupDs()} onChange={onChange} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx index d3221a4bb25..4fcd62a563f 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptions.tsx @@ -5,6 +5,7 @@ import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui'; +import { HeatmapQueryType } from '../dataquery.gen'; import { Query } from '../types'; import { EditorField } from './EditorField'; @@ -60,6 +61,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { if (query.includeExemplars) { collapsedInfo.push(`With exemplars`); } + if (query.includeHeatmap) { + collapsedInfo.push(`Heatmap: ${query.heatmapType || 'individual'}`); + } return ( @@ -156,6 +160,30 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) { /> )} + {config.featureToggles.profilesHeatmap && ( + <> + Include heatmap visualization of profile data over time.}> + ) => { + onQueryChange({ ...query, includeHeatmap: event.currentTarget.checked }); + }} + /> + + {query.includeHeatmap && ( + Select the type of heatmap aggregation.}> + onQueryChange({ ...query, heatmapType: value as HeatmapQueryType })} + /> + + )} + + )} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue index d2b279fc5ce..e13765e1583 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.cue @@ -46,6 +46,11 @@ composableKinds: DataQuery: { annotations?: bool // If set to true, exemplars will be requested includeExemplars: bool | *false + // If set to true, heatmap data will be requested + includeHeatmap: bool | *false + // Specifies the type of heatmap query + heatmapType: #HeatmapQueryType | *"individual" + #HeatmapQueryType: "individual" | "span" @cuetsy(kind="type") } }] lenses: [] diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts index 5975ca95ecd..0c7929487ff 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/dataquery.gen.ts @@ -14,6 +14,8 @@ export type PyroscopeQueryType = ('metrics' | 'profile' | 'both'); export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both'; +export type HeatmapQueryType = ('individual' | 'span'); + export interface GrafanaPyroscopeDataQuery extends common.DataQuery { /** * If set to true, the response will contain annotations @@ -23,10 +25,18 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { * Allows to group the results. */ groupBy: Array; + /** + * Specifies the type of heatmap query + */ + heatmapType: (HeatmapQueryType | 'individual'); /** * If set to true, exemplars will be requested */ includeExemplars: boolean; + /** + * If set to true, heatmap data will be requested + */ + includeHeatmap: boolean; /** * Specifies the query label selectors. */ @@ -51,7 +61,9 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { export const defaultGrafanaPyroscopeDataQuery: Partial = { groupBy: [], + heatmapType: 'individual', includeExemplars: false, + includeHeatmap: false, labelSelector: '{}', spanSelector: [], }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index 34ab7b4e521..fac831aa0dd 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -44,6 +44,8 @@ describe('Pyroscope data source', () => { profileTypeId: '', groupBy: [''], includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual', }, ]); expect(queries).toMatchObject([ @@ -120,6 +122,8 @@ describe('normalizeQuery', () => { profileTypeId: 'cpu', refId: '', includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual', }); expect(normalized).toMatchObject({ labelSelector: '{app="myapp"}', @@ -148,6 +152,8 @@ const defaultQuery = (query: Partial): Query => { profileTypeId: '', queryType: defaultPyroscopeQueryType, includeExemplars: false, + includeHeatmap: false, + heatmapType: 'individual', ...query, }; }; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts index 312ca537cd9..6f1123a804c 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts @@ -130,6 +130,8 @@ export class PyroscopeDataSource extends DataSourceWithBackend