Skip to content

Commit f2ac98a

Browse files
committed
fix: make minimum step duration configurable for eBPF profiling
This change addresses issue #4524 where users setting `collect_interval` below 15 seconds (e.g., 5s for eBPF profiling) were seeing their data displayed at 15-second intervals in the UI due to a hardcoded minimum step duration. Changes: - Added `--querier.min-step-duration` flag (default: 15s) to allow users to configure the minimum step duration for timeline calculations - Created `CalcPointIntervalWithMinInterval` function that accepts a custom minimum interval parameter - Updated HTTP handlers to pass the configurable value through the call chain - Added comprehensive tests covering eBPF use cases (1s, 5s intervals) - Maintains backward compatibility with 15-second default Users can now run Pyroscope with `--querier.min-step-duration=5s` to support fast eBPF profiling collection intervals while maintaining fine-grained resolution in the UI. Fixes #4524
1 parent a96bbfd commit f2ac98a

File tree

7 files changed

+69
-14
lines changed

7 files changed

+69
-14
lines changed

pkg/api/api.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"flag"
1111
"fmt"
1212
"net/http"
13+
"time"
1314

1415
"connectrpc.com/connect"
1516

@@ -232,8 +233,8 @@ func (a *API) RegisterFeatureFlagsServiceHandler(svc capabilitiesv1connect.Featu
232233
capabilitiesv1connect.RegisterFeatureFlagsServiceHandler(a.server.HTTP, svc, a.connectOptionsAuthLogRecovery()...)
233234
}
234235

235-
func (a *API) RegisterPyroscopeHandlers(client querierv1connect.QuerierServiceClient) {
236-
handlers := querier.NewHTTPHandlers(client)
236+
func (a *API) RegisterPyroscopeHandlers(client querierv1connect.QuerierServiceClient, minStepDuration time.Duration) {
237+
handlers := querier.NewHTTPHandlers(client, minStepDuration)
237238
a.RegisterRoute("/pyroscope/render", http.HandlerFunc(handlers.Render), a.registerOptionsReadPath()...)
238239
a.RegisterRoute("/pyroscope/render-diff", http.HandlerFunc(handlers.RenderDiff), a.registerOptionsReadPath()...)
239240
a.RegisterRoute("/pyroscope/label-values", http.HandlerFunc(handlers.LabelValues), a.registerOptionsReadPath()...)

pkg/pyroscope/modules.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ func (f *Pyroscope) initQuerier() (services.Service, error) {
262262
}
263263

264264
if !f.isModuleActive(QueryFrontend) {
265-
f.API.RegisterPyroscopeHandlers(querierSvc)
265+
f.API.RegisterPyroscopeHandlers(querierSvc, f.Cfg.Querier.MinStepDuration)
266266
f.API.RegisterQuerierServiceHandler(querierSvc)
267267
}
268268

pkg/pyroscope/modules_experimental.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (f *Pyroscope) initQueryFrontendV1() (services.Service, error) {
8282
}
8383
f.API.RegisterFrontendForQuerierHandler(f.frontend)
8484
f.API.RegisterQuerierServiceHandler(spanlogger.NewLogSpanParametersWrapper(f.frontend, queryFrontendLogger))
85-
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(f.frontend, queryFrontendLogger))
85+
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(f.frontend, queryFrontendLogger), f.Cfg.Querier.MinStepDuration)
8686
f.API.RegisterVCSServiceHandler(f.frontend)
8787
return f.frontend, nil
8888
}
@@ -104,7 +104,7 @@ func (f *Pyroscope) initQueryFrontendV2() (services.Service, error) {
104104
)
105105

106106
f.API.RegisterQuerierServiceHandler(spanlogger.NewLogSpanParametersWrapper(queryFrontend, queryFrontendLogger))
107-
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(queryFrontend, queryFrontendLogger))
107+
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(queryFrontend, queryFrontendLogger), f.Cfg.Querier.MinStepDuration)
108108
f.API.RegisterVCSServiceHandler(vcsService)
109109

110110
// New query frontend does not have any state.
@@ -148,7 +148,7 @@ func (f *Pyroscope) initQueryFrontendV12() (services.Service, error) {
148148

149149
f.API.RegisterFrontendForQuerierHandler(f.frontend)
150150
f.API.RegisterQuerierServiceHandler(spanlogger.NewLogSpanParametersWrapper(handler, queryFrontendLogger))
151-
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(handler, queryFrontendLogger))
151+
f.API.RegisterPyroscopeHandlers(spanlogger.NewLogSpanParametersWrapper(handler, queryFrontendLogger), f.Cfg.Querier.MinStepDuration)
152152
f.API.RegisterVCSServiceHandler(vcsService)
153153

154154
return f.frontend, nil

pkg/querier/http.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"strconv"
1010
"strings"
11+
"time"
1112

1213
"connectrpc.com/connect"
1314
"github.com/gogo/status"
@@ -31,12 +32,16 @@ import (
3132
httputil "github.com/grafana/pyroscope/pkg/util/http"
3233
)
3334

34-
func NewHTTPHandlers(client querierv1connect.QuerierServiceClient) *QueryHandlers {
35-
return &QueryHandlers{client}
35+
func NewHTTPHandlers(client querierv1connect.QuerierServiceClient, minStepDuration time.Duration) *QueryHandlers {
36+
return &QueryHandlers{
37+
client: client,
38+
minStepDuration: minStepDuration,
39+
}
3640
}
3741

3842
type QueryHandlers struct {
39-
client querierv1connect.QuerierServiceClient
43+
client querierv1connect.QuerierServiceClient
44+
minStepDuration time.Duration
4045
}
4146

4247
// LabelValues only returns the label values for the given label name.
@@ -186,7 +191,7 @@ func (q *QueryHandlers) Render(w http.ResponseWriter, req *http.Request) {
186191
return err
187192
})
188193

189-
timelineStep := timeline.CalcPointInterval(selectParams.Start, selectParams.End)
194+
timelineStep := timeline.CalcPointIntervalWithMinInterval(selectParams.Start, selectParams.End, q.minStepDuration)
190195
var resSeries *connect.Response[querierv1.SelectSeriesResponse]
191196
g.Go(func() error {
192197
var err error

pkg/querier/querier.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,16 @@ import (
4545
)
4646

4747
type Config struct {
48-
PoolConfig clientpool.PoolConfig `yaml:"pool_config,omitempty"`
49-
QueryStoreAfter time.Duration `yaml:"query_store_after" category:"advanced"`
48+
PoolConfig clientpool.PoolConfig `yaml:"pool_config,omitempty"`
49+
QueryStoreAfter time.Duration `yaml:"query_store_after" category:"advanced"`
50+
MinStepDuration time.Duration `yaml:"min_step_duration" category:"advanced"`
5051
}
5152

5253
// RegisterFlags registers distributor-related flags.
5354
func (cfg *Config) RegisterFlags(fs *flag.FlagSet) {
5455
cfg.PoolConfig.RegisterFlagsWithPrefix("querier", fs)
5556
fs.DurationVar(&cfg.QueryStoreAfter, "querier.query-store-after", 4*time.Hour, "The time after which a metric should be queried from storage and not just ingesters. 0 means all queries are sent to store. If this option is enabled, the time range of the query sent to the store-gateway will be manipulated to ensure the query end is not more recent than 'now - query-store-after'.")
57+
fs.DurationVar(&cfg.MinStepDuration, "querier.min-step-duration", 15*time.Second, "Minimum step duration for time series queries. This is the minimum resolution/interval displayed in the UI timeline. Lower values allow for finer-grained profiling resolution when using fast collection intervals (e.g., eBPF with collect_interval < 15s).")
5658
}
5759

5860
type Limits interface {

pkg/querier/timeline/calculator.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,29 @@ var (
1212
)
1313

1414
// CalcPointInterval calculates the appropriate interval between each point (aka step)
15+
// using the default minimum interval of 15 seconds.
1516
// Note that its main usage is with SelectSeries, therefore its
1617
// * inputs are in ms
1718
// * output is in seconds
1819
func CalcPointInterval(fromMs int64, untilMs int64) float64 {
20+
return CalcPointIntervalWithMinInterval(fromMs, untilMs, DefaultMinInterval)
21+
}
22+
23+
// CalcPointIntervalWithMinInterval calculates the appropriate interval between each point (aka step)
24+
// with a custom minimum interval. This allows for finer-grained resolution when using fast
25+
// collection intervals (e.g., eBPF with collect_interval < 15s).
26+
// Note that its main usage is with SelectSeries, therefore its
27+
// * inputs are in ms
28+
// * output is in seconds
29+
func CalcPointIntervalWithMinInterval(fromMs int64, untilMs int64, minInterval time.Duration) float64 {
1930
resolution := DefaultRes
2031

2132
fromNano := fromMs * 1000000
2233
untilNano := untilMs * 1000000
2334
calculatedIntervalNano := time.Duration((untilNano - fromNano) / resolution)
2435

25-
if calculatedIntervalNano < DefaultMinInterval {
26-
return DefaultMinInterval.Seconds()
36+
if calculatedIntervalNano < minInterval {
37+
return minInterval.Seconds()
2738
}
2839

2940
return roundInterval(calculatedIntervalNano).Seconds()

pkg/querier/timeline/calculator_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,39 @@ func Test_CalcPointInterval(t *testing.T) {
3838
}
3939

4040
}
41+
42+
func Test_CalcPointIntervalWithMinInterval(t *testing.T) {
43+
TestDate := time.Date(2023, time.April, 18, 1, 2, 3, 4, time.UTC)
44+
45+
testCases := []struct {
46+
name string
47+
start time.Time
48+
end time.Time
49+
minInterval time.Duration
50+
want int64
51+
}{
52+
// eBPF use case: 5 second minimum interval
53+
{name: "5s min interval - 1 second", start: TestDate, end: TestDate.Add(1 * time.Second), minInterval: 5 * time.Second, want: 5},
54+
{name: "5s min interval - 1 hour", start: TestDate, end: TestDate.Add(1 * time.Hour), minInterval: 5 * time.Second, want: 5},
55+
{name: "5s min interval - 7 days", start: TestDate, end: TestDate.Add(7 * 24 * time.Hour), minInterval: 5 * time.Second, want: 300},
56+
{name: "5s min interval - 30 days", start: TestDate, end: TestDate.Add(30 * 24 * time.Hour), minInterval: 5 * time.Second, want: 1800},
57+
58+
// 1 second minimum interval
59+
{name: "1s min interval - 1 second", start: TestDate, end: TestDate.Add(1 * time.Second), minInterval: 1 * time.Second, want: 1},
60+
{name: "1s min interval - 10 seconds", start: TestDate, end: TestDate.Add(10 * time.Second), minInterval: 1 * time.Second, want: 1},
61+
{name: "1s min interval - 1 hour", start: TestDate, end: TestDate.Add(1 * time.Hour), minInterval: 1 * time.Second, want: 2},
62+
63+
// Default 15 second minimum interval (should match Test_CalcPointInterval)
64+
{name: "15s min interval - 1 second", start: TestDate, end: TestDate.Add(1 * time.Second), minInterval: 15 * time.Second, want: 15},
65+
{name: "15s min interval - 1 hour", start: TestDate, end: TestDate.Add(1 * time.Hour), minInterval: 15 * time.Second, want: 15},
66+
{name: "15s min interval - 7 days", start: TestDate, end: TestDate.Add(7 * 24 * time.Hour), minInterval: 15 * time.Second, want: 300},
67+
}
68+
69+
for _, tc := range testCases {
70+
t.Run(tc.name, func(t *testing.T) {
71+
got := timeline.CalcPointIntervalWithMinInterval(tc.start.UnixMilli(), tc.end.UnixMilli(), tc.minInterval)
72+
73+
assert.Equal(t, float64(tc.want), got)
74+
})
75+
}
76+
}

0 commit comments

Comments
 (0)