Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3c412d8
feat: add proto defintions for individual profile retrieval API
marcsanmi Nov 10, 2025
ecc872c
update Exemplar labels proto description
marcsanmi Nov 11, 2025
da30088
Remove GetProfile endpoint and add span_id
marcsanmi Nov 12, 2025
55638d6
feat: add profileID to query pipeline for exemplar support
marcsanmi Nov 13, 2025
4579273
feat: add v1 compatibility for empty profileID
marcsanmi Nov 13, 2025
5ecabc4
feat: add exemplar sampling in TimeSeriesBuilder
marcsanmi Nov 13, 2025
3bdf010
feat: add multi-block exemplar support
marcsanmi Nov 13, 2025
0561e29
fix linting
marcsanmi Nov 13, 2025
665df91
fix:utf-8 error
marcsanmi Nov 14, 2025
a66bf96
Merge branch 'main' into api/individual-profile-retrieval
marcsanmi Nov 14, 2025
52cbf75
feat: add include_exemplars flag to timeseries APIs
marcsanmi Nov 14, 2025
2d3046f
Merge branch 'api/individual-profile-retrieval' of github.com:grafana…
marcsanmi Nov 14, 2025
9fb577b
Merge branch 'api/individual-profile-retrieval' into marcsanmi/add-ti…
marcsanmi Nov 14, 2025
0ed6061
refactor: move profileID fetching to query_time_series
marcsanmi Nov 14, 2025
26e3085
reafctor: add lazy label fetching for exemplars to have all the labels
marcsanmi Nov 14, 2025
f133b64
Add ExemplarType enum
marcsanmi Nov 14, 2025
091abe5
refactor: aggregate exemplar values across blocks before selecting top-n
marcsanmi Nov 17, 2025
98b88e5
Remove option from exemplar_type
marcsanmi Nov 17, 2025
e2bb6b7
Merge branch 'main' into api/individual-profile-retrieval
marcsanmi Nov 17, 2025
3d153bf
Merge remote-tracking branch 'origin/api/individual-profile-retrieval…
marcsanmi Nov 17, 2025
7b4f7d4
Add exemplartype processing
marcsanmi Nov 17, 2025
138fe60
feat: add validation for unimplemented API features
marcsanmi Nov 17, 2025
1b6fae8
feat: implement ExemplarBuilder with label intersection and deduplica…
marcsanmi Nov 19, 2025
d38bf30
Merge branch 'main' into marcsanmi/add-timeseries-exemplars
marcsanmi Nov 19, 2025
e260b6c
Merge branch 'main' into marcsanmi/add-timeseries-exemplars
marcsanmi Nov 19, 2025
14cf4a7
resolve merge conflicts from main
marcsanmi Nov 21, 2025
f03f93f
Address review
marcsanmi Nov 21, 2025
716ba6a
Merge branch 'marcsanmi/add-timeseries-exemplars' of github.com:grafa…
marcsanmi Nov 21, 2025
e7ac28a
Merge branch 'main' into marcsanmi/add-timeseries-exemplars
marcsanmi Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ func (q *QueryFrontend) SelectSeries(
Query: []*queryv1.Query{{
QueryType: queryv1.QueryType_QUERY_TIME_SERIES,
TimeSeries: &queryv1.TimeSeriesQuery{
Step: c.Msg.GetStep(),
GroupBy: c.Msg.GetGroupBy(),
Limit: c.Msg.GetLimit(),
Step: c.Msg.GetStep(),
GroupBy: c.Msg.GetGroupBy(),
Limit: c.Msg.GetLimit(),
ExemplarType: c.Msg.GetExemplarType(),
},
}},
})
Expand Down
102 changes: 102 additions & 0 deletions pkg/model/exemplar_builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package model

import (
"sort"

"github.com/prometheus/common/model"

typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
)

// ExemplarBuilder builds exemplars for a single time series.
type ExemplarBuilder struct {
labelSetIndex map[uint64]int
labelSets []Labels
exemplars []exemplar
}

type exemplar struct {
timestamp int64
profileID string
labelSetRef int
value uint64
}

// NewExemplarBuilder creates a new ExemplarBuilder.
func NewExemplarBuilder() *ExemplarBuilder {
return &ExemplarBuilder{
labelSetIndex: make(map[uint64]int),
labelSets: make([]Labels, 0),
exemplars: make([]exemplar, 0),
}
}

// Add adds an exemplar with its full labels.
func (eb *ExemplarBuilder) Add(fp model.Fingerprint, labels Labels, ts int64, profileID string, value uint64) {
if profileID == "" {
return
}

labelSetIdx, exists := eb.labelSetIndex[uint64(fp)]
if !exists {
eb.labelSets = append(eb.labelSets, labels.Clone())
labelSetIdx = len(eb.labelSets) - 1
eb.labelSetIndex[uint64(fp)] = labelSetIdx
}

eb.exemplars = append(eb.exemplars, exemplar{
timestamp: ts,
profileID: profileID,
labelSetRef: labelSetIdx,
value: value,
})
}

// Build returns the final exemplars, sorted and deduplicated.
// Exemplars with the same (profileID, timestamp) are merged by intersecting their labels.
func (eb *ExemplarBuilder) Build() []*typesv1.Exemplar {
if len(eb.exemplars) == 0 {
return nil
}

sort.Slice(eb.exemplars, func(i, j int) bool {
if eb.exemplars[i].timestamp != eb.exemplars[j].timestamp {
return eb.exemplars[i].timestamp < eb.exemplars[j].timestamp
}
return eb.exemplars[i].profileID < eb.exemplars[j].profileID
})

return eb.deduplicateAndIntersect()
}

// deduplicateAndIntersect merges exemplars with the same profileID, timestamp by intersecting their label sets.
func (eb *ExemplarBuilder) deduplicateAndIntersect() []*typesv1.Exemplar {
result := make([]*typesv1.Exemplar, 0, len(eb.exemplars))

i := 0
for i < len(eb.exemplars) {
curr := eb.exemplars[i]

labelSetsToIntersect := []Labels{eb.labelSets[curr.labelSetRef]}
j := i + 1
for j < len(eb.exemplars) &&
eb.exemplars[j].profileID == curr.profileID &&
eb.exemplars[j].timestamp == curr.timestamp {
labelSetsToIntersect = append(labelSetsToIntersect, eb.labelSets[eb.exemplars[j].labelSetRef])
j++
}

finalLabels := IntersectAll(labelSetsToIntersect)

result = append(result, &typesv1.Exemplar{
Timestamp: curr.timestamp,
ProfileId: curr.profileID,
Value: curr.value,
Labels: finalLabels,
})

i = j
}

return result
}
49 changes: 49 additions & 0 deletions pkg/model/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,3 +584,52 @@ func ServiceVersionFromLabels(lbls Labels) (ServiceVersion, bool) {
RootPath: rootPath,
}, repo != "" || gitref != "" || rootPath != ""
}

// IntersectAll returns only the labels that are present in all label sets
// with the same value. Used for merging exemplars with dynamic labels.
// Reuses the existing Intersect method by iteratively intersecting all label sets.
func IntersectAll(labelSets []Labels) Labels {
if len(labelSets) == 0 {
return nil
}
if len(labelSets) == 1 {
return labelSets[0]
}

result := labelSets[0].Clone()
for i := 1; i < len(labelSets); i++ {
result = result.Intersect(labelSets[i])
if len(result) == 0 {
return nil
}
}

if len(result) == 0 {
return nil
}
return result
}

// WithoutLabels returns a new Labels with the specified label names removed.
func (ls Labels) WithoutLabels(names ...string) Labels {
if len(names) == 0 {
return ls
}

toRemove := make(Labels, 0, len(names))
for _, name := range names {
for _, l := range ls {
if l.Name == name {
toRemove = append(toRemove, l)
break
}
}
}

if len(toRemove) == 0 {
return ls
}

result := ls.Clone()
return result.Subtract(toRemove)
}
165 changes: 165 additions & 0 deletions pkg/model/labels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,168 @@ func TestLabels_Intersect(t *testing.T) {
})
}
}

func TestLabels_IntersectAll(t *testing.T) {
tests := []struct {
name string
labelSets []Labels
expected Labels
}{
{
name: "Empty input",
labelSets: []Labels{},
expected: nil,
},
{
name: "Single label set",
labelSets: []Labels{
{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Two sets with full overlap",
labelSets: []Labels{
{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Two sets with partial overlap",
labelSets: []Labels{
{
{Name: "env", Value: "prod"},
{Name: "pod", Value: "pod-1"},
{Name: "region", Value: "us-east"},
},
{
{Name: "env", Value: "prod"},
{Name: "pod", Value: "pod-2"},
{Name: "region", Value: "us-east"},
},
},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "region", Value: "us-east"},
},
},
{
name: "No common labels",
labelSets: []Labels{
{
{Name: "env", Value: "prod"},
},
{
{Name: "region", Value: "us-east"},
},
},
expected: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := IntersectAll(test.labelSets)
assert.Equal(t, test.expected, result)
})
}
}

func TestLabels_WithoutLabels(t *testing.T) {
tests := []struct {
name string
labels Labels
remove []string
expected Labels
}{
{
name: "Remove nothing",
labels: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
remove: []string{},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Remove single label",
labels: Labels{
{Name: "env", Value: "prod"},
{Name: "pod", Value: "pod-1"},
{Name: "service", Value: "api"},
},
remove: []string{"pod"},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Remove multiple labels",
labels: Labels{
{Name: "env", Value: "prod"},
{Name: "pod", Value: "pod-1"},
{Name: "region", Value: "us-east"},
{Name: "service", Value: "api"},
},
remove: []string{"pod", "region"},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Remove non-existent label",
labels: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
remove: []string{"nonexistent"},
expected: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
},
{
name: "Remove all labels",
labels: Labels{
{Name: "env", Value: "prod"},
{Name: "service", Value: "api"},
},
remove: []string{"env", "service"},
expected: Labels{},
},
{
name: "Empty labels",
labels: Labels{},
remove: []string{"env"},
expected: Labels{},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.labels.WithoutLabels(test.remove...)
assert.Equal(t, test.expected, result)
})
}
}
Loading