diff --git a/go.mod b/go.mod index 4dd1003bbe3..8403c95db7b 100644 --- a/go.mod +++ b/go.mod @@ -112,7 +112,7 @@ require ( github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling - github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling + github.com/grafana/pyroscope/api v1.2.1-0.20260109143659-5ff77ad3011a // @grafana/observability-traces-and-profiling github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend @@ -711,9 +711,6 @@ 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 45af1906c6e..e5cad729a9e 100644 --- a/go.sum +++ b/go.sum @@ -1685,6 +1685,8 @@ 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.20260109143659-5ff77ad3011a h1:8ol+RVtrjm6rFu275xR7ChDzm4nYFNj9gWRO19p9sQI= +github.com/grafana/pyroscope/api v1.2.1-0.20260109143659-5ff77ad3011a/go.mod h1:ga4rxVfVsvUKEbmwx4/dryIRwHBYpuwP0mDB81aMR2Y= 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= @@ -2421,8 +2423,6 @@ 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/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go index a0612e70be7..a74692a0c26 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go @@ -1,43 +1,110 @@ package exemplar import ( + "sort" "time" "github.com/grafana/grafana-plugin-sdk-go/data" ) type Exemplar struct { - Id string + ProfileId string + SpanId string Value float64 Timestamp int64 + Labels map[string]string } -func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame { +type ExemplarType string + +const ( + ExemplarTypeProfile ExemplarType = "profile" + ExemplarTypeSpan ExemplarType = "span" +) + +func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar, exemplarType ExemplarType, units string) *data.Frame { frame := data.NewFrame("exemplar") frame.Meta = &data.FrameMeta{ DataTopic: data.DataTopicAnnotations, } - fields := []*data.Field{ - data.NewField("Time", nil, []time.Time{}), - data.NewField("Value", labels, []float64{}), // add labels here? - data.NewField("Id", nil, []string{}), + + // Determine display name and which ID to use based on exemplar type + displayName := "Profile ID" + if exemplarType == ExemplarTypeSpan { + displayName = "Span ID" } - fields[2].Config = &data.FieldConfig{ - DisplayName: "Profile ID", + + // Collect all unique label names across all exemplars + uniqLabelNames := make(map[string]struct{}) + for _, e := range exemplars { + for name := range e.Labels { + uniqLabelNames[name] = struct{}{} + } } for name := range labels { - fields = append(fields, data.NewField(name, nil, []string{})) + uniqLabelNames[name] = struct{}{} } + + // Initialize fields + const offset = 3 + fields := make([]*data.Field, 0, len(uniqLabelNames)+offset) + fields = append(fields, data.NewField("Time", nil, make([]time.Time, 0, len(exemplars)))) + fields = append(fields, data.NewField("Value", labels, make([]float64, 0, len(exemplars)))) // Series labels attached to Value field + fields = append(fields, data.NewField("Id", nil, make([]string, 0, len(exemplars)))) + + // Configure the Value field with units and display name + valueFieldConfig := &data.FieldConfig{ + DisplayName: "Value", + Unit: units, + } + fields[1].Config = valueFieldConfig + + // Configure the Id field with display name + idFieldConfig := &data.FieldConfig{ + DisplayName: displayName, + } + fields[2].Config = idFieldConfig + + sortedLabelNames := make([]string, 0, len(uniqLabelNames)) + for name := range uniqLabelNames { + sortedLabelNames = append(sortedLabelNames, name) + } + sort.Strings(sortedLabelNames) + + // Create fields for all label names + for _, name := range sortedLabelNames { + fields = append(fields, data.NewField(name, nil, make([]string, 0, len(exemplars)))) + } + frame.Fields = fields + row := make([]any, len(uniqLabelNames)+offset) for _, e := range exemplars { - frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id) - for name, value := range labels { - field, _ := frame.FieldByName(name) - if field != nil { - field.Append(value) - } + row[0] = time.UnixMilli(e.Timestamp) + row[1] = e.Value + + // Use the appropriate ID based on exemplar type + if exemplarType == ExemplarTypeSpan { + row[2] = e.SpanId + } else if exemplarType == ExemplarTypeProfile { + row[2] = e.ProfileId } + + // Append label values: prefer exemplar-specific values over series values + for idx, name := range sortedLabelNames { + // Check if this exemplar has this label + if value, ok := e.Labels[name]; ok { + row[idx+offset] = value + continue + } + if value, ok := labels[name]; ok { + row[idx+offset] = value + continue + } + row[idx+offset] = "" + } + + frame.AppendRow(row...) } return frame } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go index c026e5a4cd7..2af9aaf46c4 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go @@ -6,29 +6,220 @@ import ( "github.com/stretchr/testify/require" ) -func TestCreateExemplarFrame(t *testing.T) { +func TestCreateExemplarFrame_ProfileType(t *testing.T) { exemplars := []*Exemplar{ - {Id: "1", Value: 1.0, Timestamp: 100}, - {Id: "2", Value: 2.0, Timestamp: 200}, + {ProfileId: "profile-1", SpanId: "span-1", Value: 1.0, Timestamp: 100, Labels: map[string]string{"pod": "pod-1"}}, + {ProfileId: "profile-2", SpanId: "span-2", Value: 2.0, Timestamp: 200, Labels: map[string]string{"pod": "pod-2"}}, } labels := map[string]string{ - "foo": "bar", + "service": "api", } - frame := CreateExemplarFrame(labels, exemplars) + frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeProfile, "bytes") require.Equal(t, "exemplar", frame.Name) - require.Equal(t, 4, len(frame.Fields)) + // Time, Value, Id, service (from labels), pod (from exemplar labels) + require.Equal(t, 5, len(frame.Fields)) require.Equal(t, "Time", frame.Fields[0].Name) require.Equal(t, "Value", frame.Fields[1].Name) require.Equal(t, "Id", frame.Fields[2].Name) - require.Equal(t, "foo", frame.Fields[3].Name) + + // Check that Id field shows Profile ID + require.Equal(t, "Profile ID", frame.Fields[2].Config.DisplayName) rows, err := frame.RowLen() require.NoError(t, err) require.Equal(t, 2, rows) + row := frame.RowCopy(0) - require.Equal(t, 4, len(row)) + require.Equal(t, 5, len(row)) require.Equal(t, 1.0, row[1]) - require.Equal(t, "1", row[2]) - require.Equal(t, "bar", row[3]) + require.Equal(t, "profile-1", row[2]) // Should use ProfileId for profile type +} + +func TestCreateExemplarFrame_SpanType(t *testing.T) { + exemplars := []*Exemplar{ + { + ProfileId: "profile-1", + SpanId: "span-abc123", + Value: 100.0, + Timestamp: 1000, + Labels: map[string]string{ + "pod": "pod-xyz", + "namespace": "prod", + "__name__": "cpu", + }, + }, + } + labels := map[string]string{ + "service": "api", + } + frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "nanoseconds") + + require.Equal(t, "exemplar", frame.Name) + + // Check Value field configuration + valueField := frame.Fields[1] + require.Equal(t, "Value", valueField.Name) + require.Equal(t, "Value", valueField.Config.DisplayName) + require.Equal(t, "nanoseconds", valueField.Config.Unit) + + // Check Id field configuration + idField := frame.Fields[2] + require.Equal(t, "Id", idField.Name) + require.Equal(t, "Span ID", idField.Config.DisplayName) + + // Verify span ID is used for span type + rows, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, 1, rows) + + row := frame.RowCopy(0) + require.Equal(t, "span-abc123", row[2]) // Should use SpanId for span type +} + +func TestCreateExemplarFrame_AllLabelsIncluded(t *testing.T) { + exemplars := []*Exemplar{ + { + ProfileId: "profile-1", + SpanId: "span-1", + Value: 1.0, + Timestamp: 100, + Labels: map[string]string{ + "pod": "pod-1", + "__profile_type__": "cpu", + "__name__": "process_cpu", + }, + }, + } + labels := map[string]string{ + "service": "api", + } + frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "count") + + // Verify all fields are created (including private labels) + fieldNames := []string{} + for _, field := range frame.Fields { + fieldNames = append(fieldNames, field.Name) + } + + require.Contains(t, fieldNames, "Time") + require.Contains(t, fieldNames, "Value") + require.Contains(t, fieldNames, "Id") + require.Contains(t, fieldNames, "service") + require.Contains(t, fieldNames, "pod") + require.Contains(t, fieldNames, "__profile_type__") + require.Contains(t, fieldNames, "__name__") +} + +func TestCreateExemplarFrame_NoDuplicateFields(t *testing.T) { + // Test that labels in both series labels and exemplar labels don't create duplicate fields + exemplars := []*Exemplar{ + { + ProfileId: "profile-1", + SpanId: "span-1", + Value: 1.0, + Timestamp: 100, + Labels: map[string]string{ + "pod": "exemplar-pod-123", // Different value than series label + "namespace": "prod", // This is only in exemplar labels + }, + }, + } + labels := map[string]string{ + "service": "api", + "pod": "series-pod-456", // This is also in exemplar labels but with different value + } + frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "short") + + // Count how many fields have each name + fieldCounts := make(map[string]int) + for _, field := range frame.Fields { + fieldCounts[field.Name]++ + } + + // Each field name should appear exactly once + require.Equal(t, 1, fieldCounts["Time"]) + require.Equal(t, 1, fieldCounts["Value"]) + require.Equal(t, 1, fieldCounts["Id"]) + require.Equal(t, 1, fieldCounts["service"]) + require.Equal(t, 1, fieldCounts["pod"], "pod field should appear exactly once, not duplicated") + require.Equal(t, 1, fieldCounts["namespace"]) + + // Verify the exemplar-specific pod value is used (not the series value) + rows, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, 1, rows) + + podField, _ := frame.FieldByName("pod") + require.NotNil(t, podField) + require.Equal(t, "exemplar-pod-123", podField.At(0), "Should use exemplar-specific pod value, not series value") + + // Verify series label is used when exemplar doesn't have the label + serviceField, _ := frame.FieldByName("service") + require.NotNil(t, serviceField) + require.Equal(t, "api", serviceField.At(0)) + + // Verify exemplar-only label + namespaceField, _ := frame.FieldByName("namespace") + require.NotNil(t, namespaceField) + require.Equal(t, "prod", namespaceField.At(0)) +} + +func TestCreateExemplarFrame_ExemplarValueTakesPrecedence(t *testing.T) { + // Test that exemplar label values take precedence over series label values + exemplars := []*Exemplar{ + { + ProfileId: "profile-1", + SpanId: "span-1", + Value: 1.0, + Timestamp: 100, + Labels: map[string]string{ + "pod": "pod-abc", + "node": "node-xyz", + "span_name": "my-span", + }, + }, + { + ProfileId: "profile-2", + SpanId: "span-2", + Value: 2.0, + Timestamp: 200, + Labels: map[string]string{ + "pod": "pod-def", + "node": "node-uvw", + "span_name": "another-span", + }, + }, + } + labels := map[string]string{ + "service": "api", + } + frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "bytes") + + // Verify we have the correct number of rows + rows, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, 2, rows) + + // Verify each exemplar has its own pod, node, and span_name values + podField, _ := frame.FieldByName("pod") + require.NotNil(t, podField) + require.Equal(t, "pod-abc", podField.At(0)) + require.Equal(t, "pod-def", podField.At(1)) + + nodeField, _ := frame.FieldByName("node") + require.NotNil(t, nodeField) + require.Equal(t, "node-xyz", nodeField.At(0)) + require.Equal(t, "node-uvw", nodeField.At(1)) + + spanNameField, _ := frame.FieldByName("span_name") + require.NotNil(t, spanNameField) + require.Equal(t, "my-span", spanNameField.At(0)) + require.Equal(t, "another-span", spanNameField.At(1)) + + // Verify series label is the same for both + serviceField, _ := frame.FieldByName("service") + require.NotNil(t, serviceField) + require.Equal(t, "api", serviceField.At(0)) + require.Equal(t, "api", serviceField.At(1)) } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go index e565722684d..e175a1a9082 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap.go @@ -1,6 +1,9 @@ package heatmap import ( + "fmt" + "sort" + "strings" "time" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -13,6 +16,98 @@ type Point struct { Counts []int64 } +// generateFrameName creates a unique frame name from labels +// If labels are empty, returns "heatmap" +// Otherwise returns "heatmap{label1=value1,label2=value2,...}" +func generateFrameName(labels map[string]string) string { + if len(labels) == 0 { + return "heatmap" + } + + // Sort label keys for consistent ordering + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build label string + pairs := make([]string, 0, len(labels)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, labels[k])) + } + + return fmt.Sprintf("heatmap{%s}", strings.Join(pairs, ",")) +} + +// fillMissingTimeSlices ensures continuous time coverage by filling gaps between data points. +// This prevents visual gaps in the heatmap. Points are assumed to be in increasing timestamp order. +func fillMissingTimeSlices(points []*Point, stepSeconds float64) []*Point { + if len(points) == 0 { + return points + } + + // Determine the common bucket structure (YMin values) + // Find the most complete bucket structure across all points + templateYMin := points[0].YMin + for _, point := range points { + if len(point.YMin) > len(templateYMin) { + templateYMin = point.YMin + } + } + + stepMs := int64(stepSeconds * 1000) + filled := make([]*Point, 0, len(points)*2) // Estimate: assume some gaps + zeroCounts := make([]int64, len(templateYMin)) + + // Process first point, normalizing bucket structure if needed + firstPoint := points[0] + if len(firstPoint.YMin) < len(templateYMin) { + paddedCounts := make([]int64, len(templateYMin)) + copy(paddedCounts, firstPoint.Counts) + filled = append(filled, &Point{ + Timestamp: firstPoint.Timestamp, + YMin: templateYMin, + Counts: paddedCounts, + }) + } else { + filled = append(filled, firstPoint) + } + + // Iterate through remaining points and fill gaps as we find them + for i := 1; i < len(points); i++ { + prevTimestamp := filled[len(filled)-1].Timestamp + currTimestamp := points[i].Timestamp + + // Fill any gaps between previous and current point + expectedTimestamp := prevTimestamp + stepMs + for expectedTimestamp < currTimestamp { + filled = append(filled, &Point{ + Timestamp: expectedTimestamp, + YMin: templateYMin, + Counts: append([]int64(nil), zeroCounts...), // Copy to avoid sharing + }) + expectedTimestamp += stepMs + } + + // Add current point, normalizing bucket structure if needed + currPoint := points[i] + if len(currPoint.YMin) < len(templateYMin) { + paddedCounts := make([]int64, len(templateYMin)) + copy(paddedCounts, currPoint.Counts) + filled = append(filled, &Point{ + Timestamp: currTimestamp, + YMin: templateYMin, + Counts: paddedCounts, + }) + } else { + filled = append(filled, currPoint) + } + } + + return filled +} + // 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) @@ -20,8 +115,17 @@ type Point struct { // - 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") +// +// Parameters: +// - labels: metric labels for the heatmap series +// - points: data points in increasing timestamp order (may have gaps in time coverage) +// - units: unit string for Y-axis values +// - stepSeconds: duration of each time bucket in seconds +// +// The function ensures continuous time coverage by filling gaps between points with zero counts. +func CreateHeatmapFrame(labels map[string]string, points []*Point, units string, stepSeconds float64) *data.Frame { + frameName := generateFrameName(labels) + frame := data.NewFrame(frameName) frame.Meta = &data.FrameMeta{ Type: "heatmap-cells", } @@ -32,20 +136,34 @@ func CreateHeatmapFrame(labels map[string]string, points []*Point, units string) 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) + // Create data fields in the order expected by heatmap-cells format + // Set interval (in milliseconds) on xMax field so frontend can calculate xMin for bucket boundaries + intervalMs := int64(stepSeconds * 1000) + frame.Fields = data.Fields{ + data.NewField("xMax", nil, make([]time.Time, 0, totalCells)).SetConfig(&data.FieldConfig{ + Interval: float64(intervalMs), + }), + data.NewField("yMin", nil, make([]float64, 0, totalCells)).SetConfig(&data.FieldConfig{ + Unit: units, + }), + data.NewField("yMax", nil, make([]float64, 0, totalCells)).SetConfig(&data.FieldConfig{ + Unit: units, + }), + data.NewField("count", labels, make([]int64, 0, totalCells)), + data.NewField("yLayout", nil, make([]int8, 0, totalCells)), + } + + if totalCells == 0 { + return frame + } + + // Fill missing time slices and normalize bucket structures + points = fillMissingTimeSlices(points, stepSeconds) // 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 @@ -61,23 +179,15 @@ func CreateHeatmapFrame(labels map[string]string, points []*Point, units string) 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), + frame.AppendRow( + timestamp, + point.YMin[i], + yMax, + point.Counts[i], + int8(0), // 0 indicates linear bucket layout + ) + } } return frame diff --git a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go index 6db92a95e1a..a14f0a33024 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/heatmap/heatmap_test.go @@ -8,21 +8,44 @@ import ( "github.com/stretchr/testify/require" ) +func TestGenerateFrameName(t *testing.T) { + t.Run("empty labels returns default name", func(t *testing.T) { + name := generateFrameName(map[string]string{}) + require.Equal(t, "heatmap", name) + }) + + t.Run("single label", func(t *testing.T) { + name := generateFrameName(map[string]string{"service": "api"}) + require.Equal(t, "heatmap{service=api}", name) + }) + + t.Run("multiple labels sorted", func(t *testing.T) { + name := generateFrameName(map[string]string{ + "service": "api", + "env": "prod", + "region": "us-west", + }) + // Labels should be sorted alphabetically + require.Equal(t, "heatmap{env=prod,region=us-west,service=api}", name) + }) +} + func TestCreateHeatmapFrame(t *testing.T) { t.Run("creates frame with correct metadata", func(t *testing.T) { + now := time.Now() points := []*Point{ { - Timestamp: time.Now().UnixMilli(), + Timestamp: now.UnixMilli(), YMin: []float64{0, 100, 200}, Counts: []int64{5, 10, 3}, }, } labels := map[string]string{"service": "api"} - frame := CreateHeatmapFrame(labels, points, "ns") + frame := CreateHeatmapFrame(labels, points, "ns", 15.0) require.NotNil(t, frame) - require.Equal(t, "heatmap", frame.Name) + require.Equal(t, "heatmap{service=api}", frame.Name) require.NotNil(t, frame.Meta) require.Equal(t, data.FrameType("heatmap-cells"), frame.Meta.Type) }) @@ -37,7 +60,7 @@ func TestCreateHeatmapFrame(t *testing.T) { }, } - frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0) require.Len(t, frame.Fields, 5) require.Equal(t, "xMax", frame.Fields[0].Name) @@ -49,7 +72,8 @@ func TestCreateHeatmapFrame(t *testing.T) { 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) + timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC) // 15 seconds later (1 step) + stepDuration := 15.0 // 15 seconds points := []*Point{ { @@ -64,9 +88,9 @@ func TestCreateHeatmapFrame(t *testing.T) { }, } - frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + frame := CreateHeatmapFrame(map[string]string{}, points, "ns", stepDuration) - // Should create 4 cells total (2 time points × 2 buckets) + // Should create 4 cells total (2 time points × 2 buckets, no gaps to fill) require.Equal(t, 4, frame.Fields[0].Len()) require.Equal(t, 4, frame.Fields[1].Len()) require.Equal(t, 4, frame.Fields[2].Len()) @@ -110,16 +134,17 @@ func TestCreateHeatmapFrame(t *testing.T) { }) t.Run("attaches labels to count field", func(t *testing.T) { + now := time.Now() points := []*Point{ { - Timestamp: time.Now().UnixMilli(), + Timestamp: now.UnixMilli(), YMin: []float64{0}, Counts: []int64{5}, }, } labels := map[string]string{"service": "api", "env": "prod"} - frame := CreateHeatmapFrame(labels, points, "ns") + frame := CreateHeatmapFrame(labels, points, "ns", 15.0) countField := frame.Fields[3] require.NotNil(t, countField.Labels) @@ -127,16 +152,34 @@ func TestCreateHeatmapFrame(t *testing.T) { require.Equal(t, "prod", countField.Labels["env"]) }) - t.Run("sets unit on yMin and yMax fields", func(t *testing.T) { + t.Run("creates unique frame name based on labels", func(t *testing.T) { + now := time.Now() points := []*Point{ { - Timestamp: time.Now().UnixMilli(), + Timestamp: now.UnixMilli(), + YMin: []float64{0}, + Counts: []int64{5}, + }, + } + labels := map[string]string{"service": "api", "env": "prod"} + + frame := CreateHeatmapFrame(labels, points, "ns", 15.0) + + // Frame name should include labels in sorted order + require.Equal(t, "heatmap{env=prod,service=api}", frame.Name) + }) + + t.Run("sets unit on yMin and yMax fields", func(t *testing.T) { + now := time.Now() + points := []*Point{ + { + Timestamp: now.UnixMilli(), YMin: []float64{0}, Counts: []int64{5}, }, } - frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0) // yMin field should have units yMinField := frame.Fields[1] @@ -156,7 +199,7 @@ func TestCreateHeatmapFrame(t *testing.T) { }) t.Run("handles empty points", func(t *testing.T) { - frame := CreateHeatmapFrame(map[string]string{}, []*Point{}, "ns") + frame := CreateHeatmapFrame(map[string]string{}, []*Point{}, "ns", 15.0) require.NotNil(t, frame) require.Len(t, frame.Fields, 5) @@ -169,7 +212,7 @@ func TestCreateHeatmapFrame(t *testing.T) { 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) + timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC) // 15 seconds later (1 step) points := []*Point{ { @@ -184,9 +227,172 @@ func TestCreateHeatmapFrame(t *testing.T) { }, } - frame := CreateHeatmapFrame(map[string]string{}, points, "ns") + frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0) - // Should create 5 cells total (3 from first point + 2 from second) - require.Equal(t, 5, frame.Fields[0].Len()) + // Should use the most complete bucket structure (3 buckets from first point) + // 2 time points × 3 buckets = 6 cells + require.Equal(t, 6, frame.Fields[0].Len()) + }) + + t.Run("fills missing time slices with zero counts", func(t *testing.T) { + timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + timestamp2 := time.Date(2024, 1, 1, 0, 0, 45, 0, time.UTC) // 45 seconds later (3 steps of 15s) + stepDuration := 15.0 // 15 seconds + + 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", stepDuration) + + // Should fill gaps: original 2 points + 2 gap points = 4 points + // Each point has 2 buckets, so 4 * 2 = 8 cells total + require.Equal(t, 8, frame.Fields[0].Len()) + + // Check timestamps are continuous + xMaxField := frame.Fields[0] + expectedTimestamps := []int64{ + timestamp1.UnixMilli(), // Original point + timestamp1.Add(15 * time.Second).UnixMilli(), // Gap fill + timestamp1.Add(30 * time.Second).UnixMilli(), // Gap fill + timestamp2.UnixMilli(), // Original point + } + + for i, expected := range expectedTimestamps { + // Each timestamp should appear twice (once per bucket) + require.Equal(t, expected, xMaxField.At(i*2).(time.Time).UnixMilli()) + require.Equal(t, expected, xMaxField.At(i*2+1).(time.Time).UnixMilli()) + } + + // Check that gap-filled cells have zero counts + countField := frame.Fields[3] + require.Equal(t, int64(5), countField.At(0)) // Original + require.Equal(t, int64(10), countField.At(1)) // Original + require.Equal(t, int64(0), countField.At(2)) // Gap fill + require.Equal(t, int64(0), countField.At(3)) // Gap fill + require.Equal(t, int64(0), countField.At(4)) // Gap fill + require.Equal(t, int64(0), countField.At(5)) // Gap fill + require.Equal(t, int64(7), countField.At(6)) // Original + require.Equal(t, int64(12), countField.At(7)) // Original + }) +} + +func TestFillMissingTimeSlices(t *testing.T) { + t.Run("no gaps returns original points", func(t *testing.T) { + timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC) + stepDuration := 15.0 + + points := []*Point{ + { + Timestamp: timestamp1.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{5, 10}, + }, + { + Timestamp: timestamp2.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{7, 12}, + }, + } + + filled := fillMissingTimeSlices(points, stepDuration) + + require.Len(t, filled, 2) + require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp) + require.Equal(t, timestamp2.UnixMilli(), filled[1].Timestamp) + }) + + t.Run("fills single gap", func(t *testing.T) { + timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + timestamp2 := time.Date(2024, 1, 1, 0, 0, 30, 0, time.UTC) // 2 steps later + stepDuration := 15.0 + + points := []*Point{ + { + Timestamp: timestamp1.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{5, 10}, + }, + { + Timestamp: timestamp2.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{7, 12}, + }, + } + + filled := fillMissingTimeSlices(points, stepDuration) + + require.Len(t, filled, 3) + require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp) + require.Equal(t, timestamp1.Add(15*time.Second).UnixMilli(), filled[1].Timestamp) + require.Equal(t, timestamp2.UnixMilli(), filled[2].Timestamp) + + // Check gap point has zero counts + require.Equal(t, []int64{0, 0}, filled[1].Counts) + require.Equal(t, []float64{0, 100}, filled[1].YMin) + }) + + t.Run("fills multiple gaps", 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) // 4 steps later + stepDuration := 15.0 + + points := []*Point{ + { + Timestamp: timestamp1.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{5, 10}, + }, + { + Timestamp: timestamp2.UnixMilli(), + YMin: []float64{0, 100}, + Counts: []int64{7, 12}, + }, + } + + filled := fillMissingTimeSlices(points, stepDuration) + + require.Len(t, filled, 5) + require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp) + require.Equal(t, timestamp1.Add(15*time.Second).UnixMilli(), filled[1].Timestamp) + require.Equal(t, timestamp1.Add(30*time.Second).UnixMilli(), filled[2].Timestamp) + require.Equal(t, timestamp1.Add(45*time.Second).UnixMilli(), filled[3].Timestamp) + require.Equal(t, timestamp2.UnixMilli(), filled[4].Timestamp) + + // Check all gap points have zero counts + for i := 1; i <= 3; i++ { + require.Equal(t, []int64{0, 0}, filled[i].Counts) + } + }) + + t.Run("handles empty points", func(t *testing.T) { + filled := fillMissingTimeSlices([]*Point{}, 15.0) + require.Len(t, filled, 0) + }) + + t.Run("handles single point", 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}, + Counts: []int64{5, 10}, + }, + } + + filled := fillMissingTimeSlices(points, 15.0) + + require.Len(t, filled, 1) + require.Equal(t, timestamp.UnixMilli(), filled[0].Timestamp) }) } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 0ee626e74c4..f1c213fd2b5 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -37,7 +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) + GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error) } // PyroscopeDatasource is a datasource for querying application performance profiles. diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index 19571945abe..80ae87a0b3e 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -55,9 +55,11 @@ type Point struct { } type Exemplar struct { - Id string + ProfileId string + SpanId string Value uint64 Timestamp int64 + Labels []*LabelPair } type ProfileResponse struct { @@ -75,6 +77,7 @@ type HeatmapPoint struct { Timestamp int64 YMin []float64 Counts []int64 + Exemplars []*Exemplar } type HeatmapSeries struct { @@ -166,10 +169,20 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l if len(p.Exemplars) > 0 { points[i].Exemplars = make([]*Exemplar, len(p.Exemplars)) for j, e := range p.Exemplars { + // Convert API labels to our LabelPair type + exemplarLabels := make([]*LabelPair, len(e.Labels)) + for k, l := range e.Labels { + exemplarLabels[k] = &LabelPair{ + Name: l.Name, + Value: l.Value, + } + } points[i].Exemplars[j] = &Exemplar{ - Id: e.ProfileId, + ProfileId: e.ProfileId, + SpanId: e.SpanId, Value: e.Value, Timestamp: e.Timestamp, + Labels: exemplarLabels, } } } @@ -190,10 +203,21 @@ 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) { +func (c *PyroscopeClient) GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error) { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetHeatmap", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector))) defer span.End() + // Determine exemplar type based on includeExemplars flag and query type + exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE + if includeExemplars { + switch queryType { + case querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN: + exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_SPAN + case querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_INDIVIDUAL: + exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL + } + } + req := connect.NewRequest(&querierv1.SelectHeatmapRequest{ ProfileTypeID: profileTypeID, LabelSelector: labelSelector, @@ -202,6 +226,8 @@ func (c *PyroscopeClient) GetHeatmap(ctx context.Context, profileTypeID string, Step: step, GroupBy: groupBy, QueryType: queryType, + Limit: limit, + ExemplarType: exemplarType, }) resp, err := c.connectClient.SelectHeatmap(ctx, req) @@ -228,10 +254,32 @@ func (c *PyroscopeClient) GetHeatmap(ctx context.Context, profileTypeID string, for k, c := range slot.Counts { counts[k] = int64(c) } + + // Process exemplars if present + exemplars := make([]*Exemplar, len(slot.Exemplars)) + for k, e := range slot.Exemplars { + // Convert API labels to our LabelPair type + exemplarLabels := make([]*LabelPair, len(e.Labels)) + for i, l := range e.Labels { + exemplarLabels[i] = &LabelPair{ + Name: l.Name, + Value: l.Value, + } + } + exemplars[k] = &Exemplar{ + ProfileId: e.ProfileId, + SpanId: e.SpanId, + Value: e.Value, + Timestamp: e.Timestamp, + Labels: exemplarLabels, + } + } + points[j] = &HeatmapPoint{ Timestamp: slot.Timestamp, YMin: slot.YMin, Counts: counts, + Exemplars: exemplars, } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go index ee2e55e1f99..bc9e708cdab 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go @@ -40,7 +40,7 @@ func Test_PyroscopeClient(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}}, + {Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{ProfileId: "id1", SpanId: "", Value: 3, Timestamp: 1000, Labels: []*LabelPair{}}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{ProfileId: "id2", SpanId: "", Value: 1, Timestamp: 2000, Labels: []*LabelPair{}}}}}}, }, Units: "short", Label: "alloc_objects", diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index 1af60b6508b..2bb53cb0bc5 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -95,6 +95,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont heatmapType = querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN } + // Check if exemplars should be included + includeExemplars := qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) + + stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds()) heatmapResp, err := d.client.GetHeatmap( gCtx, profileTypeId, @@ -102,8 +106,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont query.TimeRange.From.UnixMilli(), query.TimeRange.To.UnixMilli(), qm.GroupBy, - math.Max(query.Interval.Seconds(), parsedInterval.Seconds()), + stepDuration, heatmapType, + qm.Limit, + includeExemplars, ) if err != nil { span.RecordError(err) @@ -114,22 +120,51 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont responseMutex.Lock() defer responseMutex.Unlock() + + // Determine exemplar type based on heatmap type + exemplarType := exemplar.ExemplarTypeProfile + if heatmapType == querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN { + exemplarType = exemplar.ExemplarTypeSpan + } + 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 + // Convert HeatmapPoint to heatmap.Point and collect exemplars points := make([]*heatmap.Point, len(series.Points)) + exemplars := []*exemplar.Exemplar{} for i, p := range series.Points { points[i] = &heatmap.Point{ Timestamp: p.Timestamp, YMin: p.YMin, Counts: p.Counts, } + // Collect exemplars from this point + for _, e := range p.Exemplars { + // Convert exemplar labels from slice to map + exemplarLabels := make(map[string]string) + for _, l := range e.Labels { + exemplarLabels[l.Name] = l.Value + } + exemplars = append(exemplars, &exemplar.Exemplar{ + ProfileId: e.ProfileId, + SpanId: e.SpanId, + Value: float64(e.Value), + Timestamp: e.Timestamp, + Labels: exemplarLabels, + }) + } } - heatmapFrame := heatmap.CreateHeatmapFrame(labels, points, heatmapResp.Units) + heatmapFrame := heatmap.CreateHeatmapFrame(labels, points, heatmapResp.Units, stepDuration) response.Frames = append(response.Frames, heatmapFrame) + + // Create exemplar frame if we have exemplars + if len(exemplars) > 0 { + exemplarFrame := exemplar.CreateExemplarFrame(labels, exemplars, exemplarType, heatmapResp.Units) + response.Frames = append(response.Frames, exemplarFrame) + } } return nil } @@ -604,10 +639,17 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration } } for _, e := range point.Exemplars { + // Convert exemplar labels from slice to map + exemplarLabels := make(map[string]string) + for _, l := range e.Labels { + exemplarLabels[l.Name] = l.Value + } exemplars = append(exemplars, &exemplar.Exemplar{ - Id: e.Id, + ProfileId: e.ProfileId, + SpanId: e.SpanId, Value: transformation(float64(e.Value)), Timestamp: e.Timestamp, + Labels: exemplarLabels, }) } } @@ -616,7 +658,8 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration frames = append(frames, frame) if len(exemplars) > 0 { - frame := exemplar.CreateExemplarFrame(labels, exemplars) + // Series queries always use individual profiles + frame := exemplar.CreateExemplarFrame(labels, exemplars, exemplar.ExemplarTypeProfile, displayUnit) frames = append(frames, frame) } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index 395aced4c6e..7a7acea8f1b 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -662,7 +662,7 @@ func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector }, nil } -func (f *FakeClient) GetHeatmap(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType) (*HeatmapResponse, error) { +func (f *FakeClient) GetHeatmap(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error) { return &HeatmapResponse{ Series: []*HeatmapSeries{ { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index dc0d4a90f24..560f396d547 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -417,6 +417,7 @@ export class Explore extends PureComponent { { width: number; height: number; data: DataFrame[]; + annotations?: DataFrame[]; eventBus: EventBus; timeRange: TimeRange; timeZone: TimeZone; @@ -30,8 +30,8 @@ interface Props extends Pick { export const HeatmapContainer = ({ data, + annotations, eventBus, - height, width, timeRange, timeZone, @@ -40,31 +40,34 @@ export const HeatmapContainer = ({ loadingState, statusMessage, }: Props) => { - const slicedData = useMemo(() => { - return data.slice(0, MAX_NUMBER_OF_HEATMAPS); - }, [data]); - + // Backend already respects query limit parameter, so render all frames return ( - - {(innerWidth, innerHeight) => ( - + {data.map((frame, index) => ( + - )} - + statusMessage={statusMessage} + > + {(innerWidth, innerHeight) => ( + + )} + + ))} + ); }; diff --git a/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx b/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx index f0bdf4df090..321953a4520 100644 --- a/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx +++ b/public/app/features/explore/Heatmap/HeatmapExploreContainer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { createContext, useMemo } from 'react'; import { AbsoluteTimeRange, @@ -15,8 +15,12 @@ import { TooltipDisplayMode } from '@grafana/schema'; import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor'; +// Context to provide splitOpen function to components that need to manually construct explore links +export const ExploreSplitOpenContext = createContext<{ splitOpen?: SplitOpen; timeRange?: TimeRange }>({}); + interface Props { data: DataFrame[]; + annotations?: DataFrame[]; height: number; width: number; timeRange: TimeRange; @@ -29,6 +33,7 @@ interface Props { export function HeatmapExploreContainer({ data, + annotations, height, width, timeZone, @@ -55,26 +60,32 @@ export function HeatmapExploreContainer({ legend: { show: true, }, + exemplars: { + color: 'rgba(31, 120, 193, 0.7)', // Standard Grafana blue to match graph series + }, }), [] ); return ( - + + + ); } diff --git a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx index 269243f1ad8..8f56c576ae6 100644 --- a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useRef, useState, ReactNode } from 'react'; +import { ReactElement, useContext, useEffect, useRef, useState, ReactNode } from 'react'; import * as React from 'react'; import uPlot from 'uplot'; @@ -13,7 +13,7 @@ import { PanelData, } from '@grafana/data'; import { HeatmapCellLayout } from '@grafana/schema'; -import { TooltipDisplayMode, useTheme2 } from '@grafana/ui'; +import { TextLink, TooltipDisplayMode, useTheme2 } from '@grafana/ui'; import { VizTooltipContent, VizTooltipFooter, @@ -25,9 +25,8 @@ import { } from '@grafana/ui/internal'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { ExploreSplitOpenContext } from 'app/features/explore/Heatmap/HeatmapExploreContainer'; import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; -import { getDisplayValuesAndLinks } from 'app/features/visualization/data-hover/DataHoverView'; -import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip'; import { getDataLinks, getFieldActions } from '../status-history/utils'; import { isTooltipScrollable } from '../timeseries/utils'; @@ -59,25 +58,194 @@ interface HeatmapTooltipProps { canExecuteActions?: boolean; } -export const HeatmapTooltip = (props: HeatmapTooltipProps) => { - if (props.seriesIdx === 2) { - const dispValuesAndLinks = getDisplayValuesAndLinks(props.dataRef.current!.exemplars!, props.dataIdxs[2]!); +// Custom exemplar tooltip that renders field values with inline links +const HeatmapExemplarTooltip = ({ + exemplarFrame, + rowIndex, + isPinned, + maxHeight, +}: { + exemplarFrame: PanelData['series'][0]; + rowIndex: number; + isPinned: boolean; + maxHeight?: number; +}) => { + const { splitOpen, timeRange } = useContext(ExploreSplitOpenContext); - if (dispValuesAndLinks == null) { - return null; + // Get visible fields (excluding private labels starting with __) + const visibleFields = exemplarFrame.fields.filter( + (f) => !Boolean(f.config.custom?.hideFrom?.tooltip) && !f.name.startsWith('__') + ); + + if (visibleFields.length === 0) { + return null; + } + + // Find time field + const timeField = visibleFields.find((f) => f.name === 'Time'); + const timeValue = timeField + ? formattedValueToString( + timeField.display ? timeField.display(timeField.values[rowIndex]) : { text: `${timeField.values[rowIndex]}` } + ) + : ''; + + // Prepare fields to display (excluding time) + const displayFields = visibleFields.filter((f) => f !== timeField); + + const theme = useTheme2(); + + // Helper to check if this is a Span ID field (not Profile ID) + const isSpanIdField = (field: Field) => { + return field.config.displayName === 'Span ID'; + }; + + // Helper to check if a label name needs quoting + // Label names with non-alphanumeric characters (except _) need to be quoted + const needsQuoting = (labelName: string): boolean => { + // Valid unquoted label names: start with letter or underscore, followed by alphanumeric or underscore + return !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(labelName); + }; + + // Helper to quote a label name if needed + const quoteLabelName = (labelName: string): string => { + if (needsQuoting(labelName)) { + // Escape any quotes in the label name itself, then wrap in quotes + return `"${labelName.replace(/"/g, '\\"')}"`; + } + return labelName; + }; + + // Helper to escape label values for Pyroscope label selector + // Need to escape backslashes and quotes + const escapeLabelValue = (value: string): string => { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + }; + + // Helper to manually generate Explore query for span profile + const handleSpanIdClick = (spanId: string) => { + if (!splitOpen || !timeRange) { + return; } - const { displayValues, links } = dispValuesAndLinks; + // Extract profileTypeId from __profile_type__ field + const profileTypeField = exemplarFrame.fields.find((f) => f.name === '__profile_type__'); + const profileTypeId = profileTypeField ? String(profileTypeField.values[rowIndex]) : ''; + + // Collect all label fields (excluding Time, Value, Id, and private labels starting with __) + const labelFields = exemplarFrame.fields.filter( + (f) => f.name !== 'Time' && f.name !== 'Value' && f.name !== 'Id' && !f.name.startsWith('__') + ); + + // Build label selector with properly escaped values and quoted label names if needed + // Format: {label1="value1", "label-2"="value2", ...} + const labelParts = labelFields.map((field) => { + const value = field.values[rowIndex]; + const quotedLabelName = quoteLabelName(field.name); + const escapedValue = escapeLabelValue(String(value)); + return `${quotedLabelName}="${escapedValue}"`; + }); + const labelSelector = labelParts.length > 0 ? `{${labelParts.join(', ')}}` : ''; + + // Get timestamp from Time field and create a narrow time window around it (+/- 30 seconds) + const timeMs = timeField?.values[rowIndex]; + const timestamp = timeMs instanceof Date ? timeMs.getTime() : timeMs; + + // Create a 60-second window centered on the exemplar (30s before and after) + const windowMs = 30 * 1000; // 30 seconds in milliseconds + const narrowRange = { + from: new Date(timestamp - windowMs).toISOString(), + to: new Date(timestamp + windowMs).toISOString(), + }; + + // Construct the query for span profile + const query = { + queryType: 'profile', + spanSelector: [spanId], + labelSelector, + profileTypeId, + groupBy: [], + }; + + // Open in explore with the span profile query and narrow time range + splitOpen({ + queries: [query], + range: narrowRange, + }); + }; + + return ( + + + +
+ + + {displayFields.map((field, i) => { + const value = field.values[rowIndex]; + const fieldDisplay = field.display ? field.display(value) : { text: `${value}`, numeric: +value }; + const fieldName = getFieldDisplayName(field, exemplarFrame); + const valueString = formattedValueToString(fieldDisplay); + + // Check if this is a Span ID field that should have a link + const isSpanId = isSpanIdField(field); + const hasLink = isSpanId && splitOpen; + + return ( + + + + + ); + })} + +
+ {fieldName}: + + {hasLink ? ( + { + e.preventDefault(); + handleSpanIdClick(valueString); + }} + external={false} + weight="medium" + inline={false} + > + {valueString} + + ) : ( + valueString + )} +
+
+
+
+ ); +}; + +export const HeatmapTooltip = (props: HeatmapTooltipProps) => { + if (props.seriesIdx === 2) { + const exemplarFrame = props.dataRef.current!.exemplars!; + const rowIndex = props.dataIdxs[2]!; return ( - ({ - label: dispVal.name, - value: dispVal.valueString, - }))} - links={links} - maxHeight={props.maxHeight} + ); } diff --git a/public/app/plugins/panel/heatmap/fields.ts b/public/app/plugins/panel/heatmap/fields.ts index 4fbf6097c77..6e32ee3c889 100644 --- a/public/app/plugins/panel/heatmap/fields.ts +++ b/public/app/plugins/panel/heatmap/fields.ts @@ -95,7 +95,34 @@ export function prepareHeatmapData({ cacheFieldDisplayNames(frames); - const exemplars = annotations?.find((f) => f.name === 'exemplar'); + // Helper function to check if two label sets match + const labelsMatch = (labels1: Record | undefined, labels2: Record | undefined) => { + if (!labels1 && !labels2) { + return true; + } + if (!labels1 || !labels2) { + return false; + } + const keys1 = Object.keys(labels1); + const keys2 = Object.keys(labels2); + if (keys1.length !== keys2.length) { + return false; + } + return keys1.every((key) => labels1[key] === labels2[key]); + }; + + // Find the first heatmap frame to get its labels + const heatmapFrame = frames.find((f) => f.meta?.type === DataFrameType.HeatmapCells); + const heatmapLabels = heatmapFrame?.fields.find((f) => f.name === 'count')?.labels; + + // Find the exemplar frame that matches the heatmap frame's labels + const exemplars = annotations?.find((f) => { + if (f.name !== 'exemplar') { + return false; + } + const valueField = f.fields.find((field) => field.name === 'Value'); + return labelsMatch(heatmapLabels, valueField?.labels); + }); exemplars?.fields.forEach((field) => { field.getLinks = getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables);