WIP: Add heatmap to Pyroscope
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+12
@@ -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<string>;
|
||||
/**
|
||||
* 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<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
heatmapType: 'individual',
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
Generated
+4
@@ -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"
|
||||
)
|
||||
|
||||
+12
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+15
-2
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ const dummyProps: Props = {
|
||||
showCustom: true,
|
||||
showNodeGraph: true,
|
||||
showFlameGraph: true,
|
||||
showHeatmap: false,
|
||||
splitOpen: jest.fn(),
|
||||
splitted: false,
|
||||
eventBus: new EventBusSrv(),
|
||||
|
||||
@@ -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<Props, ExploreState> {
|
||||
);
|
||||
}
|
||||
|
||||
renderHeatmapPanel(width: number) {
|
||||
const { queryResponse, timeZone } = this.props;
|
||||
|
||||
return (
|
||||
<ContentOutlineItem panelId="Heatmap" title={t('explore.explore.title-heatmap', 'Heatmap')} icon="fire">
|
||||
<HeatmapContainer
|
||||
data={queryResponse.heatmapFrames}
|
||||
height={400}
|
||||
width={width}
|
||||
timeRange={queryResponse.timeRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={this.onUpdateTimeRange}
|
||||
splitOpenFn={this.onSplitOpen('heatmap')}
|
||||
loadingState={queryResponse.state}
|
||||
eventBus={this.graphEventBus}
|
||||
/>
|
||||
</ContentOutlineItem>
|
||||
);
|
||||
}
|
||||
|
||||
renderTablePanel(width: number) {
|
||||
const { exploreId, timeZone, eventBus } = this.props;
|
||||
return (
|
||||
@@ -587,6 +608,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
showTrace,
|
||||
showCustom,
|
||||
showNodeGraph,
|
||||
showHeatmap,
|
||||
showFlameGraph,
|
||||
showLogsSample,
|
||||
correlationEditorDetails,
|
||||
@@ -611,6 +633,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
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<Props, ExploreState> {
|
||||
{this.renderGraphPanel(width)}
|
||||
</ErrorBoundaryAlert>
|
||||
)}
|
||||
{showHeatmap && (
|
||||
<ErrorBoundaryAlert boundaryName="explore-heatmap-panel">
|
||||
{this.renderHeatmapPanel(width)}
|
||||
</ErrorBoundaryAlert>
|
||||
)}
|
||||
{showRawPrometheus && (
|
||||
<ErrorBoundaryAlert boundaryName="explore-raw-prometheus">
|
||||
{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),
|
||||
|
||||
@@ -62,6 +62,7 @@ const setup = (propOverrides = {}) => {
|
||||
customFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
graphResult: null,
|
||||
logsResult: null,
|
||||
|
||||
@@ -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<PanelChromeProps, 'statusMessage'> {
|
||||
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 (
|
||||
<PanelChrome
|
||||
title={t('heatmap.container.title', 'Heatmap')}
|
||||
width={width}
|
||||
height={height}
|
||||
loadingState={loadingState}
|
||||
statusMessage={statusMessage}
|
||||
>
|
||||
{(innerWidth, innerHeight) => (
|
||||
<HeatmapExploreContainer
|
||||
data={slicedData}
|
||||
height={innerHeight}
|
||||
width={innerWidth}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={onChangeTime}
|
||||
splitOpenFn={splitOpenFn}
|
||||
loadingState={loadingState}
|
||||
eventBus={eventBus}
|
||||
/>
|
||||
)}
|
||||
</PanelChrome>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
|
||||
<PanelRenderer
|
||||
data={{
|
||||
series: data,
|
||||
timeRange,
|
||||
state: loadingState,
|
||||
}}
|
||||
pluginId="heatmap"
|
||||
title=""
|
||||
width={width}
|
||||
height={height}
|
||||
onChangeTimeRange={onChangeTime}
|
||||
timeZone={timeZone}
|
||||
options={panelOptions}
|
||||
/>
|
||||
</DataLinksContext.Provider>
|
||||
);
|
||||
}
|
||||
+2
@@ -133,6 +133,8 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) {
|
||||
uid: profilesDataSourceSettings.uid,
|
||||
},
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ function createEmptyQueryResponse(): ExplorePanelData {
|
||||
traceFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
customFrames: [],
|
||||
tableFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
|
||||
@@ -25,6 +25,7 @@ export const mockExplorePanelData = (props?: MockProps): Observable<ExplorePanel
|
||||
nodeGraphFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
rawPrometheusResult: null,
|
||||
heatmapFrames: [],
|
||||
series: [],
|
||||
state: LoadingState.Done,
|
||||
tableFrames: [],
|
||||
|
||||
@@ -1324,6 +1324,7 @@ const processQueryResponse = (state: ExploreItemState, action: PayloadAction<Que
|
||||
flameGraphFrames,
|
||||
rawPrometheusFrames,
|
||||
customFrames,
|
||||
heatmapFrames,
|
||||
} = response;
|
||||
|
||||
if (error) {
|
||||
@@ -1353,6 +1354,7 @@ const processQueryResponse = (state: ExploreItemState, action: PayloadAction<Que
|
||||
showNodeGraph: !!nodeGraphFrames.length,
|
||||
showRawPrometheus: !!rawPrometheusFrames.length,
|
||||
showFlameGraph: !!flameGraphFrames.length,
|
||||
showHeatmap: !!heatmapFrames.length,
|
||||
showCustom: !!customFrames?.length,
|
||||
clearedAtIndex: state.isLive ? state.clearedAtIndex : null,
|
||||
};
|
||||
|
||||
@@ -88,6 +88,7 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({
|
||||
traceFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
customFrames: [],
|
||||
tableFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
|
||||
@@ -108,6 +108,7 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
|
||||
nodeGraphFrames: [],
|
||||
customFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
rawPrometheusResult: null,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+4
@@ -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> } = { props: {} }) {
|
||||
groupBy: [],
|
||||
limit: 42,
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
}}
|
||||
datasource={setupDs()}
|
||||
onChange={onChange}
|
||||
|
||||
+28
@@ -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 (
|
||||
<Stack gap={0} direction="column">
|
||||
@@ -156,6 +160,30 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
{config.featureToggles.profilesHeatmap && (
|
||||
<>
|
||||
<EditorField label={'Heatmap'} tooltip={<>Include heatmap visualization of profile data over time.</>}>
|
||||
<InlineSwitch
|
||||
value={query.includeHeatmap || false}
|
||||
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onQueryChange({ ...query, includeHeatmap: event.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
{query.includeHeatmap && (
|
||||
<EditorField label={'Heatmap Type'} tooltip={<>Select the type of heatmap aggregation.</>}>
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: 'individual', label: 'Individual', description: 'Show individual profile samples' },
|
||||
{ value: 'span', label: 'Span', description: 'Aggregate by span duration' },
|
||||
]}
|
||||
value={query.heatmapType || 'individual'}
|
||||
onChange={(value) => onQueryChange({ ...query, heatmapType: value as HeatmapQueryType })}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</QueryOptionGroup>
|
||||
</Stack>
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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<string>;
|
||||
/**
|
||||
* 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<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
heatmapType: 'individual',
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -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>): Query => {
|
||||
profileTypeId: '',
|
||||
queryType: defaultPyroscopeQueryType,
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
...query,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -130,6 +130,8 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD
|
||||
profileTypeId: '',
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ export interface ExploreItemState {
|
||||
showTrace?: boolean;
|
||||
showNodeGraph?: boolean;
|
||||
showFlameGraph?: boolean;
|
||||
showHeatmap?: boolean;
|
||||
showCustom?: boolean;
|
||||
|
||||
/**
|
||||
@@ -281,6 +282,7 @@ export interface ExplorePanelData extends PanelData {
|
||||
nodeGraphFrames: DataFrame[];
|
||||
rawPrometheusFrames: DataFrame[];
|
||||
flameGraphFrames: DataFrame[];
|
||||
heatmapFrames: DataFrame[];
|
||||
graphResult: DataFrame[] | null;
|
||||
tableResult: DataFrame[] | null;
|
||||
logsResult: LogsModel | null;
|
||||
|
||||
Reference in New Issue
Block a user