Files
grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation/annotation.go
T
Aleksandar Petrov e3f5a65372 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
2025-08-29 13:14:22 +02:00

124 lines
3.6 KiB
Go

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
}