Pyroscope: Process and display sampling annotations (#109707)
* pyroscope: process sampling annotations * Enable annotations in classic explore * Run prettier * Revert unneeded change to plugin.json * Tweak wording in sampling annotation * Fix test * Disable annotations by default
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package annotation
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
type TimedAnnotation struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Annotation *typesv1.ProfileAnnotation `json:"annotation"`
|
||||
}
|
||||
|
||||
func (ta *TimedAnnotation) getKey() string {
|
||||
return ta.Annotation.Key
|
||||
}
|
||||
|
||||
func (ta *TimedAnnotation) getValue() string {
|
||||
return ta.Annotation.Value
|
||||
}
|
||||
|
||||
type profileAnnotationKey string
|
||||
|
||||
const (
|
||||
// ProfileAnnotationKeyThrottled is an identifier for throttling annotations
|
||||
ProfileAnnotationKeyThrottled profileAnnotationKey = "pyroscope.ingest.throttled"
|
||||
// ProfileAnnotationKeySampled is an identifier for sampling annotations
|
||||
ProfileAnnotationKeySampled profileAnnotationKey = "pyroscope.ingest.sampled"
|
||||
)
|
||||
|
||||
type processedProfileAnnotation struct {
|
||||
id string
|
||||
text string
|
||||
time int64
|
||||
timeEnd int64
|
||||
isRegion bool
|
||||
}
|
||||
|
||||
type grafanaAnnotationData struct {
|
||||
ids []string
|
||||
times []time.Time
|
||||
timeEnds []time.Time
|
||||
texts []string
|
||||
isRegions []bool
|
||||
}
|
||||
|
||||
func (ga *grafanaAnnotationData) add(a *processedProfileAnnotation) {
|
||||
// simple de-duplication, assuming annotations are ordered
|
||||
if len(ga.ids) > 0 {
|
||||
lastIdx := len(ga.ids) - 1
|
||||
if a.id == ga.ids[lastIdx] {
|
||||
// duplicate annotation, extend the previous annotation and discard the rest
|
||||
ga.timeEnds[lastIdx] = time.UnixMilli(a.timeEnd)
|
||||
return
|
||||
}
|
||||
}
|
||||
ga.ids = append(ga.ids, a.id)
|
||||
ga.times = append(ga.times, time.UnixMilli(a.time))
|
||||
ga.timeEnds = append(ga.timeEnds, time.UnixMilli(a.timeEnd))
|
||||
ga.isRegions = append(ga.isRegions, a.isRegion)
|
||||
ga.texts = append(ga.texts, a.text)
|
||||
}
|
||||
|
||||
// convertAnnotation converts a Pyroscope profile annotation into a Grafana annotation
|
||||
func convertAnnotation(timedAnnotation *TimedAnnotation) (*processedProfileAnnotation, error) {
|
||||
switch timedAnnotation.getKey() {
|
||||
case string(ProfileAnnotationKeySampled):
|
||||
return convertSamplingAnnotation(timedAnnotation.getValue(), timedAnnotation.Timestamp)
|
||||
case string(ProfileAnnotationKeyThrottled):
|
||||
return convertThrottlingAnnotation(timedAnnotation.getValue(), timedAnnotation.Timestamp)
|
||||
default:
|
||||
// Currently, we only support throttling and sampling annotations
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func processAnnotations(timedAnnotations []*TimedAnnotation) (*grafanaAnnotationData, error) {
|
||||
result := &grafanaAnnotationData{
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
for _, timedAnnotation := range timedAnnotations {
|
||||
if timedAnnotation == nil || timedAnnotation.Annotation == nil {
|
||||
continue
|
||||
}
|
||||
processed, err := convertAnnotation(timedAnnotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if processed != nil {
|
||||
result.add(processed)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateAnnotationFrame creates a Grafana data frame from annotation data
|
||||
func CreateAnnotationFrame(annotations []*TimedAnnotation) (*data.Frame, error) {
|
||||
annotationData, err := processAnnotations(annotations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeField := data.NewField("time", nil, annotationData.times)
|
||||
timeEndField := data.NewField("timeEnd", nil, annotationData.timeEnds)
|
||||
textField := data.NewField("text", nil, annotationData.texts)
|
||||
isRegionField := data.NewField("isRegion", nil, annotationData.isRegions)
|
||||
colorField := data.NewField("color", nil, make([]string, len(annotationData.times)))
|
||||
|
||||
frame := data.NewFrame("annotations")
|
||||
frame.Fields = data.Fields{timeField, timeEndField, textField, isRegionField, colorField}
|
||||
frame.SetMeta(&data.FrameMeta{
|
||||
DataTopic: data.DataTopicAnnotations,
|
||||
})
|
||||
|
||||
return frame, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package annotation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertAnnotation(t *testing.T) {
|
||||
t.Run("converts a valid throttling annotation", func(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, processed)
|
||||
require.Contains(t, processed.text, "Ingestion limit (1.0 GiB/day) reached")
|
||||
require.Contains(t, processed.text, "day")
|
||||
require.Equal(t, int64(1609455600000), processed.time)
|
||||
require.Equal(t, int64(1609459200000), processed.timeEnd) // LimitResetTime * 1000
|
||||
})
|
||||
|
||||
t.Run("converts a valid sampling annotation", func(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"source": {"usageGroup":"group-1","probability":0.1}}}`
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeySampled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, processed)
|
||||
require.Contains(t, processed.text, "Profile volume reduced by 90.00% for this service.")
|
||||
require.Equal(t, int64(1609455600000), processed.time)
|
||||
require.Equal(t, int64(1609455600000), processed.timeEnd)
|
||||
})
|
||||
|
||||
t.Run("ignores non-throttling annotations", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: "some.other.key",
|
||||
Value: `{"test":"value"}`,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, processed)
|
||||
})
|
||||
|
||||
t.Run("handles invalid annotation data", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: `invalid json`,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, processed)
|
||||
require.Contains(t, err.Error(), "error parsing annotation data")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessAnnotations(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
|
||||
t.Run("processes multiple annotations", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
{
|
||||
Timestamp: 1609459200000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := processAnnotations(annotations)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(result.times))
|
||||
require.Equal(t, 1, len(result.timeEnds))
|
||||
require.Equal(t, 1, len(result.texts))
|
||||
require.Equal(t, 1, len(result.isRegions))
|
||||
})
|
||||
|
||||
t.Run("handles empty annotations list", func(t *testing.T) {
|
||||
result, err := processAnnotations([]*TimedAnnotation{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(result.times))
|
||||
require.Equal(t, 0, len(result.timeEnds))
|
||||
require.Equal(t, 0, len(result.texts))
|
||||
require.Equal(t, 0, len(result.isRegions))
|
||||
})
|
||||
|
||||
t.Run("handles nil annotations", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{nil}
|
||||
result, err := processAnnotations(annotations)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(result.times))
|
||||
})
|
||||
|
||||
t.Run("handles invalid annotation data", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: `invalid json`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := processAnnotations(annotations)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Contains(t, err.Error(), "error parsing annotation data")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGrafanaAnnotationDataAdd(t *testing.T) {
|
||||
t.Run("adds first annotation", func(t *testing.T) {
|
||||
ga := &grafanaAnnotationData{
|
||||
ids: []string{},
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
annotation := &processedProfileAnnotation{
|
||||
id: "test-id-1",
|
||||
text: "Test annotation 1",
|
||||
time: 1609455600000,
|
||||
timeEnd: 1609459200000,
|
||||
isRegion: true,
|
||||
}
|
||||
|
||||
ga.add(annotation)
|
||||
|
||||
require.Equal(t, 1, len(ga.ids))
|
||||
require.Equal(t, "test-id-1", ga.ids[0])
|
||||
require.Equal(t, time.UnixMilli(1609455600000), ga.times[0])
|
||||
require.Equal(t, time.UnixMilli(1609459200000), ga.timeEnds[0])
|
||||
require.Equal(t, "Test annotation 1", ga.texts[0])
|
||||
require.Equal(t, true, ga.isRegions[0])
|
||||
})
|
||||
|
||||
t.Run("adds different annotations", func(t *testing.T) {
|
||||
ga := &grafanaAnnotationData{
|
||||
ids: []string{},
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
annotation1 := &processedProfileAnnotation{
|
||||
id: "test-id-1",
|
||||
text: "Test annotation 1",
|
||||
time: 1609455600000,
|
||||
timeEnd: 1609459200000,
|
||||
isRegion: true,
|
||||
}
|
||||
|
||||
annotation2 := &processedProfileAnnotation{
|
||||
id: "test-id-2",
|
||||
text: "Test annotation 2",
|
||||
time: 1609463800000,
|
||||
timeEnd: 1609467400000,
|
||||
isRegion: false,
|
||||
}
|
||||
|
||||
ga.add(annotation1)
|
||||
ga.add(annotation2)
|
||||
|
||||
require.Equal(t, 2, len(ga.ids))
|
||||
require.Equal(t, "test-id-1", ga.ids[0])
|
||||
require.Equal(t, "test-id-2", ga.ids[1])
|
||||
require.Equal(t, time.UnixMilli(1609455600000), ga.times[0])
|
||||
require.Equal(t, time.UnixMilli(1609463800000), ga.times[1])
|
||||
})
|
||||
|
||||
t.Run("removes duplicates and extends timeEnd", func(t *testing.T) {
|
||||
ga := &grafanaAnnotationData{
|
||||
ids: []string{},
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
annotation1 := &processedProfileAnnotation{
|
||||
id: "duplicate-id",
|
||||
text: "First occurrence",
|
||||
time: 1609455600000,
|
||||
timeEnd: 1609459200000,
|
||||
isRegion: true,
|
||||
}
|
||||
|
||||
annotation2 := &processedProfileAnnotation{
|
||||
id: "duplicate-id",
|
||||
text: "Second occurrence (should be ignored)",
|
||||
time: 1609460000000,
|
||||
timeEnd: 1609463600000,
|
||||
isRegion: false,
|
||||
}
|
||||
|
||||
ga.add(annotation1)
|
||||
ga.add(annotation2)
|
||||
|
||||
require.Equal(t, 1, len(ga.ids))
|
||||
require.Equal(t, 1, len(ga.times))
|
||||
require.Equal(t, 1, len(ga.timeEnds))
|
||||
require.Equal(t, 1, len(ga.texts))
|
||||
require.Equal(t, 1, len(ga.isRegions))
|
||||
|
||||
require.Equal(t, "duplicate-id", ga.ids[0])
|
||||
require.Equal(t, time.UnixMilli(1609455600000), ga.times[0]) // Original time
|
||||
require.Equal(t, time.UnixMilli(1609463600000), ga.timeEnds[0]) // Extended timeEnd
|
||||
require.Equal(t, "First occurrence", ga.texts[0]) // Original text
|
||||
require.Equal(t, true, ga.isRegions[0]) // Original isRegion
|
||||
})
|
||||
|
||||
t.Run("handles multiple duplicates correctly", func(t *testing.T) {
|
||||
ga := &grafanaAnnotationData{
|
||||
ids: []string{},
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
annotation1 := &processedProfileAnnotation{
|
||||
id: "id-1",
|
||||
text: "Annotation 1",
|
||||
time: 1609455600000,
|
||||
timeEnd: 1609459200000,
|
||||
isRegion: true,
|
||||
}
|
||||
|
||||
// Add duplicate of first
|
||||
annotation1Duplicate := &processedProfileAnnotation{
|
||||
id: "id-1",
|
||||
text: "Annotation 1 duplicate",
|
||||
time: 1609460000000,
|
||||
timeEnd: 1609470000000,
|
||||
isRegion: false,
|
||||
}
|
||||
|
||||
// Add a second, unique annotation
|
||||
annotation2 := &processedProfileAnnotation{
|
||||
id: "id-2",
|
||||
text: "Annotation 2",
|
||||
time: 1609480000000,
|
||||
timeEnd: 1609490000000,
|
||||
isRegion: false,
|
||||
}
|
||||
|
||||
// Add duplicate of second
|
||||
annotation2Duplicate := &processedProfileAnnotation{
|
||||
id: "id-2",
|
||||
text: "Annotation 2 duplicate",
|
||||
time: 1609500000000,
|
||||
timeEnd: 1609510000000,
|
||||
isRegion: true,
|
||||
}
|
||||
|
||||
ga.add(annotation1)
|
||||
ga.add(annotation1Duplicate)
|
||||
ga.add(annotation2)
|
||||
ga.add(annotation2Duplicate)
|
||||
|
||||
require.Equal(t, 2, len(ga.ids))
|
||||
require.Equal(t, "id-1", ga.ids[0])
|
||||
require.Equal(t, "id-2", ga.ids[1])
|
||||
|
||||
// The first annotation should have an extended timeEnd
|
||||
require.Equal(t, time.UnixMilli(1609455600000), ga.times[0])
|
||||
require.Equal(t, time.UnixMilli(1609470000000), ga.timeEnds[0])
|
||||
require.Equal(t, "Annotation 1", ga.texts[0])
|
||||
require.Equal(t, true, ga.isRegions[0])
|
||||
|
||||
// The second annotation should have an extended timeEnd
|
||||
require.Equal(t, time.UnixMilli(1609480000000), ga.times[1])
|
||||
require.Equal(t, time.UnixMilli(1609510000000), ga.timeEnds[1])
|
||||
require.Equal(t, "Annotation 2", ga.texts[1])
|
||||
require.Equal(t, false, ga.isRegions[1])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAnnotationFrame(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
|
||||
t.Run("creates frame with correct fields", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(ProfileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
frame, err := CreateAnnotationFrame(annotations)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, frame)
|
||||
|
||||
require.Equal(t, "annotations", frame.Name)
|
||||
require.Equal(t, data.DataTopicAnnotations, frame.Meta.DataTopic)
|
||||
|
||||
require.Equal(t, 5, len(frame.Fields))
|
||||
require.Equal(t, "time", frame.Fields[0].Name)
|
||||
require.Equal(t, "timeEnd", frame.Fields[1].Name)
|
||||
require.Equal(t, "text", frame.Fields[2].Name)
|
||||
require.Equal(t, "isRegion", frame.Fields[3].Name)
|
||||
require.Equal(t, "color", frame.Fields[4].Name)
|
||||
|
||||
require.Equal(t, 1, frame.Fields[0].Len())
|
||||
require.Equal(t, time.UnixMilli(1609455600000), frame.Fields[0].At(0))
|
||||
require.Equal(t, time.UnixMilli(1609459200000), frame.Fields[1].At(0))
|
||||
require.Contains(t, frame.Fields[2].At(0).(string), "Ingestion limit")
|
||||
})
|
||||
|
||||
t.Run("handles empty annotations list", func(t *testing.T) {
|
||||
frame, err := CreateAnnotationFrame([]*TimedAnnotation{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, frame)
|
||||
require.Equal(t, 5, len(frame.Fields))
|
||||
require.Equal(t, 0, frame.Fields[0].Len())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package annotation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type annotationWithSamplingBody struct {
|
||||
Body profileSampledAnnotation `json:"body"`
|
||||
}
|
||||
|
||||
type profileSampledAnnotation struct {
|
||||
Source *samplingSource `json:"source"`
|
||||
}
|
||||
|
||||
type samplingSource struct {
|
||||
UsageGroup string `json:"usageGroup"`
|
||||
Probability float64 `json:"probability"`
|
||||
}
|
||||
|
||||
func convertSamplingAnnotation(raw string, timestamp int64) (*processedProfileAnnotation, error) {
|
||||
var profileAnnotation annotationWithSamplingBody
|
||||
err := json.Unmarshal([]byte(raw), &profileAnnotation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing annotation data: %w", err)
|
||||
}
|
||||
if profileAnnotation.Body.Source == nil {
|
||||
return nil, fmt.Errorf("error parsing sampling annotation data: source is nil")
|
||||
}
|
||||
|
||||
samplingInfo := profileAnnotation.Body.Source
|
||||
if samplingInfo.Probability == 1.0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
reductionPercentage := (1 - samplingInfo.Probability) * 100
|
||||
id := fmt.Sprintf("%s-%.0f", samplingInfo.UsageGroup, reductionPercentage)
|
||||
text := fmt.Sprintf("Profile volume reduced by %.2f%% for this service.", reductionPercentage)
|
||||
|
||||
return &processedProfileAnnotation{
|
||||
id: id,
|
||||
text: text,
|
||||
time: timestamp,
|
||||
timeEnd: timestamp,
|
||||
isRegion: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package annotation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
type annotationWithThrottlingBody struct {
|
||||
Body profileThrottledAnnotation `json:"body"`
|
||||
}
|
||||
|
||||
type profileThrottledAnnotation struct {
|
||||
PeriodType string `json:"periodType"`
|
||||
PeriodLimitMb float64 `json:"periodLimitMb"`
|
||||
LimitResetTime int64 `json:"limitResetTime"`
|
||||
SamplingPeriodSec float64 `json:"samplingPeriodSec"`
|
||||
SamplingRequests int64 `json:"samplingRequests"`
|
||||
UsageGroup string `json:"usageGroup"`
|
||||
}
|
||||
|
||||
func convertThrottlingAnnotation(raw string, timestamp int64) (*processedProfileAnnotation, error) {
|
||||
var profileAnnotation annotationWithThrottlingBody
|
||||
err := json.Unmarshal([]byte(raw), &profileAnnotation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing annotation data: %w", err)
|
||||
}
|
||||
|
||||
throttlingInfo := profileAnnotation.Body
|
||||
limit := humanize.IBytes(uint64(throttlingInfo.PeriodLimitMb * 1024 * 1024))
|
||||
id := fmt.Sprintf("%s-%s-%d", throttlingInfo.PeriodType, limit, throttlingInfo.LimitResetTime)
|
||||
|
||||
return &processedProfileAnnotation{
|
||||
id: id,
|
||||
text: fmt.Sprintf("Ingestion limit (%s/%s) reached", limit, throttlingInfo.PeriodType),
|
||||
time: timestamp,
|
||||
timeEnd: throttlingInfo.LimitResetTime * 1000,
|
||||
isRegion: throttlingInfo.LimitResetTime < time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package pyroscope
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
// profileAnnotationKey represents the key for different types of annotations
|
||||
type profileAnnotationKey string
|
||||
|
||||
const (
|
||||
// profileAnnotationKeyThrottled is the key for throttling annotations
|
||||
profileAnnotationKeyThrottled profileAnnotationKey = "pyroscope.ingest.throttled"
|
||||
)
|
||||
|
||||
// ProfileAnnotation represents the parsed annotation data
|
||||
type ProfileAnnotation struct {
|
||||
Body ProfileThrottledAnnotation `json:"body"`
|
||||
}
|
||||
|
||||
// ProfileThrottledAnnotation contains throttling information
|
||||
type ProfileThrottledAnnotation struct {
|
||||
PeriodType string `json:"periodType"`
|
||||
PeriodLimitMb float64 `json:"periodLimitMb"`
|
||||
LimitResetTime int64 `json:"limitResetTime"`
|
||||
SamplingPeriodSec float64 `json:"samplingPeriodSec"`
|
||||
SamplingRequests int64 `json:"samplingRequests"`
|
||||
UsageGroup string `json:"usageGroup"`
|
||||
}
|
||||
|
||||
// processedProfileAnnotation represents a processed annotation ready for display
|
||||
type processedProfileAnnotation struct {
|
||||
text string
|
||||
time int64
|
||||
timeEnd int64
|
||||
isRegion bool
|
||||
duplicateTracker int64
|
||||
}
|
||||
|
||||
// grafanaAnnotationData holds slices of processed annotation data
|
||||
type grafanaAnnotationData struct {
|
||||
times []time.Time
|
||||
timeEnds []time.Time
|
||||
texts []string
|
||||
isRegions []bool
|
||||
}
|
||||
|
||||
// convertAnnotation converts a Pyroscope profile annotation into a Grafana annotation
|
||||
func convertAnnotation(timedAnnotation *TimedAnnotation, duplicateTracker int64) (*processedProfileAnnotation, error) {
|
||||
if timedAnnotation.getKey() != string(profileAnnotationKeyThrottled) {
|
||||
// Currently we only support throttling annotations
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var profileAnnotation ProfileAnnotation
|
||||
err := json.Unmarshal([]byte(timedAnnotation.getValue()), &profileAnnotation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing annotation data: %w", err)
|
||||
}
|
||||
|
||||
throttlingInfo := profileAnnotation.Body
|
||||
|
||||
if duplicateTracker == throttlingInfo.LimitResetTime {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
limit := humanize.IBytes(uint64(throttlingInfo.PeriodLimitMb * 1024 * 1024))
|
||||
return &processedProfileAnnotation{
|
||||
text: fmt.Sprintf("Ingestion limit (%s/%s) reached", limit, throttlingInfo.PeriodType),
|
||||
time: timedAnnotation.Timestamp,
|
||||
timeEnd: throttlingInfo.LimitResetTime * 1000,
|
||||
isRegion: throttlingInfo.LimitResetTime < time.Now().Unix(),
|
||||
duplicateTracker: throttlingInfo.LimitResetTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// processAnnotations processes a slice of TimedAnnotation and returns grafanaAnnotationData
|
||||
func processAnnotations(timedAnnotations []*TimedAnnotation) (*grafanaAnnotationData, error) {
|
||||
result := &grafanaAnnotationData{
|
||||
times: []time.Time{},
|
||||
timeEnds: []time.Time{},
|
||||
texts: []string{},
|
||||
isRegions: []bool{},
|
||||
}
|
||||
|
||||
var duplicateTracker int64
|
||||
|
||||
for _, timedAnnotation := range timedAnnotations {
|
||||
if timedAnnotation == nil || timedAnnotation.Annotation == nil {
|
||||
continue
|
||||
}
|
||||
processed, err := convertAnnotation(timedAnnotation, duplicateTracker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if processed != nil {
|
||||
result.times = append(result.times, time.UnixMilli(processed.time))
|
||||
result.timeEnds = append(result.timeEnds, time.UnixMilli(processed.timeEnd))
|
||||
result.isRegions = append(result.isRegions, processed.isRegion)
|
||||
result.texts = append(result.texts, processed.text)
|
||||
duplicateTracker = processed.duplicateTracker
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// createAnnotationFrame creates a data frame for annotations
|
||||
func createAnnotationFrame(annotations []*TimedAnnotation) (*data.Frame, error) {
|
||||
annotationData, err := processAnnotations(annotations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeField := data.NewField("time", nil, annotationData.times)
|
||||
timeEndField := data.NewField("timeEnd", nil, annotationData.timeEnds)
|
||||
textField := data.NewField("text", nil, annotationData.texts)
|
||||
isRegionField := data.NewField("isRegion", nil, annotationData.isRegions)
|
||||
colorField := data.NewField("color", nil, make([]string, len(annotationData.times)))
|
||||
|
||||
frame := data.NewFrame("annotations")
|
||||
frame.Fields = data.Fields{timeField, timeEndField, textField, isRegionField, colorField}
|
||||
frame.SetMeta(&data.FrameMeta{
|
||||
DataTopic: data.DataTopicAnnotations,
|
||||
})
|
||||
|
||||
return frame, nil
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package pyroscope
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertAnnotation(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
|
||||
t.Run("processes valid annotation", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, processed)
|
||||
require.Contains(t, processed.text, "Ingestion limit (1.0 GiB/day) reached")
|
||||
require.Contains(t, processed.text, "day")
|
||||
require.Equal(t, int64(1609455600000), processed.time)
|
||||
require.Equal(t, int64(1609459200000), processed.timeEnd) // LimitResetTime * 1000
|
||||
require.Equal(t, int64(1609459200), processed.duplicateTracker)
|
||||
})
|
||||
|
||||
t.Run("ignores non-throttling annotations", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: "some.other.key",
|
||||
Value: `{"test":"value"}`,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation, 0)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, processed)
|
||||
})
|
||||
|
||||
t.Run("handles invalid annotation data", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: `invalid json`,
|
||||
},
|
||||
}
|
||||
|
||||
processed, err := convertAnnotation(timedAnnotation, 0)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, processed)
|
||||
require.Contains(t, err.Error(), "error parsing annotation data")
|
||||
})
|
||||
|
||||
t.Run("skips duplicate annotations", func(t *testing.T) {
|
||||
timedAnnotation := &TimedAnnotation{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
}
|
||||
|
||||
// First call should process the annotation
|
||||
processed1, err := convertAnnotation(timedAnnotation, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, processed1)
|
||||
|
||||
// Second call with the same duplicateTracker should skip
|
||||
processed2, err := convertAnnotation(timedAnnotation, processed1.duplicateTracker)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, processed2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessAnnotations(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
|
||||
t.Run("processes multiple annotations", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
{
|
||||
Timestamp: 1609459200000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := processAnnotations(annotations)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(result.times))
|
||||
require.Equal(t, 1, len(result.timeEnds))
|
||||
require.Equal(t, 1, len(result.texts))
|
||||
require.Equal(t, 1, len(result.isRegions))
|
||||
})
|
||||
|
||||
t.Run("handles empty annotations list", func(t *testing.T) {
|
||||
result, err := processAnnotations([]*TimedAnnotation{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(result.times))
|
||||
require.Equal(t, 0, len(result.timeEnds))
|
||||
require.Equal(t, 0, len(result.texts))
|
||||
require.Equal(t, 0, len(result.isRegions))
|
||||
})
|
||||
|
||||
t.Run("handles nil annotations", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{nil}
|
||||
result, err := processAnnotations(annotations)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(result.times))
|
||||
})
|
||||
|
||||
t.Run("handles invalid annotation data", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: `invalid json`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := processAnnotations(annotations)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Contains(t, err.Error(), "error parsing annotation data")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAnnotationFrame(t *testing.T) {
|
||||
rawAnnotation := `{"body":{"periodType":"day","periodLimitMb":1024,"limitResetTime":1609459200}}`
|
||||
|
||||
t.Run("creates frame with correct fields", func(t *testing.T) {
|
||||
annotations := []*TimedAnnotation{
|
||||
{
|
||||
Timestamp: 1609455600000,
|
||||
Annotation: &typesv1.ProfileAnnotation{
|
||||
Key: string(profileAnnotationKeyThrottled),
|
||||
Value: rawAnnotation,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
frame, err := createAnnotationFrame(annotations)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, frame)
|
||||
|
||||
require.Equal(t, "annotations", frame.Name)
|
||||
require.Equal(t, data.DataTopicAnnotations, frame.Meta.DataTopic)
|
||||
|
||||
require.Equal(t, 5, len(frame.Fields))
|
||||
require.Equal(t, "time", frame.Fields[0].Name)
|
||||
require.Equal(t, "timeEnd", frame.Fields[1].Name)
|
||||
require.Equal(t, "text", frame.Fields[2].Name)
|
||||
require.Equal(t, "isRegion", frame.Fields[3].Name)
|
||||
require.Equal(t, "color", frame.Fields[4].Name)
|
||||
|
||||
require.Equal(t, 1, frame.Fields[0].Len())
|
||||
require.Equal(t, time.UnixMilli(1609455600000), frame.Fields[0].At(0))
|
||||
require.Equal(t, time.UnixMilli(1609459200000), frame.Fields[1].At(0))
|
||||
require.Contains(t, frame.Fields[2].At(0).(string), "Ingestion limit")
|
||||
})
|
||||
|
||||
t.Run("handles empty annotations list", func(t *testing.T) {
|
||||
frame, err := createAnnotationFrame([]*TimedAnnotation{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, frame)
|
||||
require.Equal(t, 5, len(frame.Fields))
|
||||
require.Equal(t, 0, frame.Fields[0].Len())
|
||||
})
|
||||
}
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/live"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
"github.com/xlab/treeprint"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery"
|
||||
)
|
||||
|
||||
type queryModel struct {
|
||||
@@ -454,19 +455,6 @@ func walkTree(tree *ProfileTree, fn func(tree *ProfileTree)) {
|
||||
}
|
||||
}
|
||||
|
||||
type TimedAnnotation struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Annotation *typesv1.ProfileAnnotation `json:"annotation"`
|
||||
}
|
||||
|
||||
func (ta *TimedAnnotation) getKey() string {
|
||||
return ta.Annotation.Key
|
||||
}
|
||||
|
||||
func (ta *TimedAnnotation) getValue() string {
|
||||
return ta.Annotation.Value
|
||||
}
|
||||
|
||||
// isCumulativeProfile determines if a profile type requires rate calculation using the metadata registry
|
||||
func isCumulativeProfile(profileTypeID string) bool {
|
||||
registry := GetProfileMetadataRegistry()
|
||||
@@ -500,7 +488,7 @@ func convertToRateUnit(originalUnit string) string {
|
||||
|
||||
func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDurationSec float64, profileTypeID string) ([]*data.Frame, error) {
|
||||
frames := make([]*data.Frame, 0, len(resp.Series))
|
||||
annotations := make([]*TimedAnnotation, 0)
|
||||
annotations := make([]*annotation.TimedAnnotation, 0)
|
||||
|
||||
for _, series := range resp.Series {
|
||||
// We create separate data frames as the series may not have the same length
|
||||
@@ -555,7 +543,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
valueField.Append(value)
|
||||
if withAnnotations {
|
||||
for _, a := range point.Annotations {
|
||||
annotations = append(annotations, &TimedAnnotation{
|
||||
annotations = append(annotations, &annotation.TimedAnnotation{
|
||||
Timestamp: point.Timestamp,
|
||||
Annotation: a,
|
||||
})
|
||||
@@ -568,7 +556,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
}
|
||||
|
||||
if len(annotations) > 0 {
|
||||
frame, err := createAnnotationFrame(annotations)
|
||||
frame, err := annotation.CreateAnnotationFrame(annotations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
)
|
||||
|
||||
// This is where the tests for the datasource backend live.
|
||||
@@ -313,7 +315,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) {
|
||||
Timestamp: int64(1609455600000),
|
||||
Value: 30,
|
||||
Annotations: []*typesv1.ProfileAnnotation{
|
||||
{Key: string(profileAnnotationKeyThrottled), Value: rawAnnotation},
|
||||
{Key: string(annotation.ProfileAnnotationKeyThrottled), Value: rawAnnotation},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,7 +339,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) {
|
||||
Timestamp: int64(1609455600000),
|
||||
Value: 30,
|
||||
Annotations: []*typesv1.ProfileAnnotation{
|
||||
{Key: string(profileAnnotationKeyThrottled), Value: rawAnnotation},
|
||||
{Key: string(annotation.ProfileAnnotationKeyThrottled), Value: rawAnnotation},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+9
-1
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { useStyles2, RadioButtonGroup, MultiSelect, Input } from '@grafana/ui';
|
||||
import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { Query } from '../types';
|
||||
|
||||
@@ -134,6 +134,14 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={'Annotations'} tooltip={<>Include profiling annotations in the time series.</>}>
|
||||
<InlineSwitch
|
||||
value={query.annotations || false}
|
||||
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onQueryChange({ ...query, annotations: event.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
</QueryOptionGroup>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user