Compare commits

..

9 Commits

Author SHA1 Message Date
Jatinderjit Singh
f7ffbfe063 refactor: rename "linksTo" to "paramsFor" 2026-05-27 03:45:20 +05:30
Jatinderjit Singh
96a83707c1 refactor: expose alertmanagerserver.Config 2026-05-27 03:35:28 +05:30
Jatinderjit Singh
829ecd01a0 refactor(rules): source external URL via alertmanager.Config() instead of plumbing through main
Address PR feedback (therealpandey): expose a Config() accessor on the
alertmanager.Alertmanager interface and consume the external URL from it
inside signozruler.NewFactory. This drops the *url.URL parameter that
was previously threaded through cmd/community/server.go,
cmd/enterprise/server.go, and pkg/signoz/signoz.go.

Also switch BaseRule.GeneratorURL to *url.URL.JoinPath + Query so the
URL is composed correctly when the external URL carries a base path
(e.g. https://signoz.example.com/signoz) and ruleId is properly
query-encoded, as suggested in review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:49:54 +05:30
Srikanth Chekuri
93e92be231 Merge branch 'main' into host-url 2026-05-26 18:04:55 +05:30
Jatinderjit Singh
5011246bc9 Merge branch 'main' into host-url 2026-05-25 23:14:40 +05:30
Jatinderjit Singh
f88af61a7c chore: use alert overview url for generator url 2026-05-25 23:01:47 +05:30
Jatinderjit Singh
6935748fd3 refactor: simplify ExternalURLHost 2026-05-25 19:11:52 +05:30
Jatinderjit Singh
16fc710518 feat(rules)!: drop frontend source URL fallback for related logs/traces and generator URL
Always derive the host portion of the rule generator URL and the
related_logs / related_traces annotations from SIGNOZ_ALERTMANAGER_SIGNOZ_EXTERNAL__URL.
Self-hosted deployments that have not set the env var will see the
default (http://localhost:8080) in alert notifications, which makes the
need to configure it surface clearly. Revert this commit to restore the
prior fallback behavior.

Refs SigNoz/engineering-pod#5055

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:07:14 +05:30
Jatinderjit Singh
745d3bc772 feat(rules): use alertmanager external URL for related logs/traces and generator URL
Plumbs SIGNOZ_ALERTMANAGER_SIGNOZ_EXTERNAL__URL through the rule manager so
the host portion of related_logs / related_traces come from configured
external URL instead of the frontend window.location captured at
rule-creation time. Frontend-supplied source URL is retained
as a fallback when the env var has not been set so existing self-hosted
deployments keep working.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:06:07 +05:30
28 changed files with 292 additions and 632 deletions

View File

@@ -434,17 +434,6 @@ tracedetail:
max_depth_to_auto_expand: 5
# Threshold below which all spans are returned without windowing.
max_limit_to_select_all_spans: 10000
flamegraph:
# Maximum number of BFS depth levels included in a windowed response.
max_selected_levels: 50
# Maximum spans per level before sampling is applied.
max_spans_per_level: 100
# Number of highest-latency spans always included when sampling a level.
sampling_top_latency_count: 5
# Number of timestamp buckets used for uniform sampling within a level.
sampling_bucket_count: 50
# Threshold below which all spans are returned without windowing or sampling.
select_all_spans_limit: 100000
##################### Authz #################################
authz:

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net/url"
"sync"
"time"
@@ -47,13 +48,14 @@ func NewAnomalyRule(
p *ruletypes.PostableRule,
querier querier.Querier,
logger *slog.Logger,
externalURL *url.URL,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, externalURL, opts...)
if err != nil {
return nil, err
}

View File

@@ -2,6 +2,7 @@ package rules
import (
"context"
"net/url"
"testing"
"time"
@@ -120,6 +121,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
&postableRule,
nil,
logger,
mustParseURL(t, "http://localhost:8000"),
)
require.NoError(t, err)
@@ -247,7 +249,8 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
@@ -264,3 +267,10 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
})
}
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}

View File

@@ -34,6 +34,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
opts.Rule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -59,6 +60,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
opts.Rule,
opts.Logger,
opts.ManagerOpts.Prometheus,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
baserules.WithMetadataStore(opts.ManagerOpts.MetadataStore),
@@ -82,6 +84,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
opts.Rule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -141,6 +144,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
parsedRule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -162,6 +166,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
parsedRule,
opts.Logger,
opts.ManagerOpts.Prometheus,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -181,6 +186,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
parsedRule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
alertmanagermock "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -51,6 +52,7 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
fAlert := am.(*alertmanagermock.MockAlertmanager)
// mock set notification config
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
fAlert.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
// for saving temp alerts that are triggered via TestNotification
if tc.ExpectAlerts > 0 {
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
@@ -166,6 +168,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mockAM := am.(*alertmanagermock.MockAlertmanager)
// mock set notification config
mockAM.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockAM.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
// for saving temp alerts that are triggered via TestNotification
if tc.ExpectAlerts > 0 {
mockAM.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {

View File

@@ -5,6 +5,7 @@ import (
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/statsreporter"
@@ -48,6 +49,9 @@ type Alertmanager interface {
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error
// Config returns the alertmanagerserver configuration.
Config() alertmanagerserver.Config
// SetConfig sets the config for the organization.
SetConfig(context.Context, *alertmanagertypes.Config) error

View File

@@ -8,6 +8,7 @@ import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
@@ -109,6 +110,50 @@ func (_c *MockAlertmanager_Collect_Call) RunAndReturn(run func(context1 context.
return _c
}
// Config provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) Config() alertmanagerserver.Config {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Config")
}
var r0 alertmanagerserver.Config
if returnFunc, ok := ret.Get(0).(func() alertmanagerserver.Config); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(alertmanagerserver.Config)
}
return r0
}
// MockAlertmanager_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
type MockAlertmanager_Config_Call struct {
*mock.Call
}
// Config is a helper method to define mock.On call
func (_e *MockAlertmanager_Expecter) Config() *MockAlertmanager_Config_Call {
return &MockAlertmanager_Config_Call{Call: _e.mock.On("Config")}
}
func (_c *MockAlertmanager_Config_Call) Run(run func()) *MockAlertmanager_Config_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockAlertmanager_Config_Call) Return(config alertmanagerserver.Config) *MockAlertmanager_Config_Call {
_c.Call.Return(config)
return _c
}
func (_c *MockAlertmanager_Config_Call) RunAndReturn(run func() alertmanagerserver.Config) *MockAlertmanager_Config_Call {
_c.Call.Return(run)
return _c
}
// CreateChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, v)

View File

@@ -8,6 +8,7 @@ import (
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
@@ -235,6 +236,10 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
return channel, nil
}
func (provider *provider) Config() alertmanagerserver.Config {
return provider.config.Signoz.Config
}
func (provider *provider) SetConfig(ctx context.Context, config *alertmanagertypes.Config) error {
return provider.configStore.Set(ctx, config)
}

View File

@@ -48,23 +48,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
handler.OpenAPIDef{
ID: "GetFlamegraph",
Tags: []string{"tracedetail"},
Summary: "Get flamegraph view for a trace",
Description: "Returns the flamegraph view of spans for a given trace ID.",
Request: new(spantypes.PostableFlamegraph),
RequestContentType: "application/json",
Response: new(spantypes.GettableFlamegraphTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
tracesV3 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v3"
@@ -226,7 +227,7 @@ func PrepareFilters(labels map[string]string, whereClauseItems []v3.FilterItem,
return filterItems
}
func PrepareLinksToTracesV5(start, end time.Time, whereClause string) string {
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
// Traces list view expects time in nanoseconds
tr := URLShareableTimeRange{
@@ -238,7 +239,6 @@ func PrepareLinksToTracesV5(start, end time.Time, whereClause string) string {
options := URLShareableOptions{}
period, _ := json.Marshal(tr)
urlEncodedTimeRange := url.QueryEscape(string(period))
linkQuery := LinkQuery{
BuilderQuery: v3.BuilderQuery{
@@ -265,15 +265,20 @@ func PrepareLinksToTracesV5(start, end time.Time, whereClause string) string {
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
compositeQuery := url.QueryEscape(string(data))
optionsData, _ := json.Marshal(options)
urlEncodedOptions := url.QueryEscape(string(optionsData))
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
params := url.Values{}
params.Set("compositeQuery", compositeQuery)
params.Set("timeRange", string(period))
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
params.Set("endTime", strconv.FormatInt(tr.End, 10))
params.Set("options", string(optionsData))
return params
}
func PrepareLinksToLogsV5(start, end time.Time, whereClause string) string {
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
// Logs list view expects time in milliseconds
tr := URLShareableTimeRange{
@@ -285,7 +290,6 @@ func PrepareLinksToLogsV5(start, end time.Time, whereClause string) string {
options := URLShareableOptions{}
period, _ := json.Marshal(tr)
urlEncodedTimeRange := url.QueryEscape(string(period))
linkQuery := LinkQuery{
BuilderQuery: v3.BuilderQuery{
@@ -312,10 +316,15 @@ func PrepareLinksToLogsV5(start, end time.Time, whereClause string) string {
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
compositeQuery := url.QueryEscape(string(data))
optionsData, _ := json.Marshal(options)
urlEncodedOptions := url.QueryEscape(string(optionsData))
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
params := url.Values{}
params.Set("compositeQuery", compositeQuery)
params.Set("timeRange", string(period))
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
params.Set("endTime", strconv.FormatInt(tr.End, 10))
params.Set("options", string(optionsData))
return params
}

View File

@@ -6,16 +6,7 @@ import (
)
type Config struct {
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
}
type FlamegraphConfig struct {
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
Waterfall WaterfallConfig `mapstructure:"waterfall"`
}
type WaterfallConfig struct {
@@ -38,13 +29,6 @@ func newConfig() factory.Config {
MaxDepthToAutoExpand: 5,
MaxLimitToSelectAllSpans: 10_000,
},
Flamegraph: FlamegraphConfig{
MaxSelectedLevels: 50,
MaxSpansPerLevel: 100,
SamplingTopLatencySpansCount: 5,
SamplingBucketCount: 50,
SelectAllSpansLimit: 100_000,
},
}
}
@@ -61,25 +45,5 @@ func (c Config) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
}
if c.Flamegraph.MaxSelectedLevels <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.level_limit must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
}
if c.Flamegraph.MaxSpansPerLevel <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
}
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
}
if c.Flamegraph.SamplingBucketCount <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
}
if c.Flamegraph.SelectAllSpansLimit == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.max_limit_to_select_all_spans must be positive")
}
return nil
}

View File

@@ -59,19 +59,3 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableFlamegraph)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -105,64 +105,6 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary)
}
return m.getWindowedFlamegraph(ctx, traceID, req.SelectedSpanID, summary)
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
if len(fullSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans)
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.GetAllLevels(),
summary.Start.UnixMilli(), summary.End.UnixMilli(), false,
), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
minimalSpans = nil
cfg := m.config.Flamegraph
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID,
cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
if len(selectedSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, summary.Start, summary.End,
spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans),
summary.Start.UnixMilli(), summary.End.UnixMilli(), true,
), nil
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window

View File

@@ -11,12 +11,10 @@ import (
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error)
}

View File

@@ -958,7 +958,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
@@ -978,7 +978,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"sync"
"time"
@@ -23,7 +24,7 @@ type BaseRule struct {
id string
name string
orgID valuer.UUID
source string
externalURL *url.URL
handledRestart bool
// Type of the rule
@@ -138,7 +139,13 @@ func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
}
}
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts ...RuleOption) (*BaseRule, error) {
func NewBaseRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
externalURL *url.URL,
opts ...RuleOption,
) (*BaseRule, error) {
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
return nil, err
@@ -151,8 +158,8 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts .
baseRule := &BaseRule{
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName,
source: p.Source,
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
@@ -241,7 +248,17 @@ func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
func (r *BaseRule) GeneratorURL() string {
return ruletypes.PrepareRuleGeneratorURL(r.ID(), r.source)
params := url.Values{}
params.Set("ruleId", r.id)
return r.ExternalURL("alerts/overview", params)
}
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
u := r.externalURL.JoinPath(path)
if len(params) > 0 {
u.RawQuery = params.Encode()
}
return u.String()
}
func (r *BaseRule) SelectedQuery(ctx context.Context) string {

View File

@@ -3,6 +3,7 @@ package rules
import (
"context"
"fmt"
"net/url"
"testing"
"time"
@@ -18,6 +19,13 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}
// createTestSeries creates a *qbtypes.TimeSeries with the given labels and optional points
// so we don't exactly need the points in the series because the labels are used to determine if the series is new or old
// we use the labels to create a lookup key for the series and then check the first_seen timestamp for the series in the metadata table
@@ -681,7 +689,15 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
}
// Create BaseRule using NewBaseRule
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, WithQueryParser(queryParser), WithLogger(logger), WithMetadataStore(mockMetadataStore))
rule, err := NewBaseRule(
"test-rule",
valuer.GenerateUUID(),
&postableRule,
mustParseURL(t, "http://localhost:8080"),
WithQueryParser(queryParser),
WithLogger(logger),
WithMetadataStore(mockMetadataStore),
)
require.NoError(t, err)
filteredSeries, err := rule.FilterNewSeries(context.Background(), tt.evalTime, tt.series)
@@ -723,6 +739,69 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
}
}
func TestBaseRule_ExternalURL(t *testing.T) {
tests := []struct {
name string
externalURL *url.URL
want string
}{
{name: "default value returned as-is", externalURL: mustParseURL(t, "http://localhost:8080"), want: "http://localhost:8080"},
{name: "configured https host", externalURL: mustParseURL(t, "https://signoz.example.com"), want: "https://signoz.example.com"},
{name: "configured host with port", externalURL: mustParseURL(t, "http://signoz.internal:3301"), want: "http://signoz.internal:3301"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := createPostableRule(&ruletypes.AlertCompositeQuery{})
r, err := NewBaseRule("some-id", valuer.GenerateUUID(), &p, tc.externalURL)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
require.Equal(t, tc.want, r.ExternalURL("", nil))
})
}
}
func TestBaseRule_GeneratorURL(t *testing.T) {
tests := []struct {
name string
ruleID string
externalURL *url.URL
want string
}{
{
name: "configured external URL",
ruleID: "abc",
externalURL: mustParseURL(t, "https://signoz.example.com"),
want: "https://signoz.example.com/alerts/overview?ruleId=abc",
},
{
name: "default external URL is used as-is",
ruleID: "abc",
externalURL: mustParseURL(t, "http://localhost:8080"),
want: "http://localhost:8080/alerts/overview?ruleId=abc",
},
{
name: "external URL with base path is preserved",
ruleID: "abc",
externalURL: mustParseURL(t, "https://signoz.example.com/signoz"),
want: "https://signoz.example.com/signoz/alerts/overview?ruleId=abc",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := createPostableRule(&ruletypes.AlertCompositeQuery{})
r, err := NewBaseRule(tc.ruleID, valuer.GenerateUUID(), &p, tc.externalURL)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
require.Equal(t, tc.want, r.GeneratorURL())
})
}
}
// labelsKey creates a deterministic string key from a labels map
// This is used to group series by their unique label combinations
func labelsKey(lbls []*qbtypes.Label) string {

View File

@@ -150,6 +150,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
opts.Rule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
WithEvalDelay(opts.ManagerOpts.EvalDelay),
WithSQLStore(opts.SQLStore),
WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -174,6 +175,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
opts.Rule,
opts.Logger,
opts.ManagerOpts.Prometheus,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
WithSQLStore(opts.SQLStore),
WithQueryParser(opts.ManagerOpts.QueryParser),
WithMetadataStore(opts.ManagerOpts.MetadataStore),

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
alertmanagermock "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -50,6 +51,7 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
mockAM := am.(*alertmanagermock.MockAlertmanager)
// mock set notification config
mockAM.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockAM.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
// for saving temp alerts that are triggered via TestNotification
if tc.ExpectAlerts > 0 {
mockAM.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
@@ -162,6 +164,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mockAM := am.(*alertmanagermock.MockAlertmanager)
// mock set notification config
mockAM.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockAM.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
// for saving temp alerts that are triggered via TestNotification
if tc.ExpectAlerts > 0 {
mockAM.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net/url"
"time"
"github.com/prometheus/prometheus/model/labels"
@@ -34,11 +35,12 @@ func NewPromRule(
postableRule *ruletypes.PostableRule,
logger *slog.Logger,
prometheus prometheus.Prometheus,
externalURL *url.URL,
opts ...RuleOption,
) (*PromRule, error) {
opts = append(opts, WithLogger(logger))
baseRule, err := NewBaseRule(id, orgID, postableRule, opts...)
baseRule, err := NewBaseRule(id, orgID, postableRule, externalURL, opts...)
if err != nil {
return nil, err
}

View File

@@ -704,7 +704,8 @@ func TestPromRuleEval(t *testing.T) {
},
}
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, nil)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, nil, externalUrl)
if err != nil {
assert.NoError(t, err)
}
@@ -967,7 +968,8 @@ func TestPromRuleUnitCombinations(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
@@ -1083,7 +1085,8 @@ func TestPromRuleNoData(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
@@ -1316,7 +1319,8 @@ func TestMultipleThresholdPromRule(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
if err != nil {
assert.NoError(t, err)
promProvider.Close()
@@ -1453,7 +1457,8 @@ func TestPromRule_NoData(t *testing.T) {
_ = promProvider.Close()
}()
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
require.NoError(t, err)
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -1603,7 +1608,8 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
_ = promProvider.Close()
}()
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
require.NoError(t, err)
// First eval with data - should NOT alert, but populates lastTimestampWithDatapoints
@@ -1762,7 +1768,8 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
_ = promProvider.Close()
}()
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
externalUrl := mustParseURL(t, "http://localhost:8080")
rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider, externalUrl)
require.NoError(t, err)
alertsFound, err := rule.Eval(context.Background(), evalTime)

View File

@@ -49,6 +49,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
parsedRule,
opts.Querier,
opts.Logger,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
WithSendAlways(),
WithSendUnmatched(),
WithSQLStore(opts.SQLStore),
@@ -70,6 +71,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
parsedRule,
opts.Logger,
opts.ManagerOpts.Prometheus,
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
WithSendAlways(),
WithSendUnmatched(),
WithSQLStore(opts.SQLStore),

View File

@@ -38,13 +38,14 @@ func NewThresholdRule(
p *ruletypes.PostableRule,
querier querier.Querier,
logger *slog.Logger,
externalURL *url.URL,
opts ...RuleOption,
) (*ThresholdRule, error) {
logger.Info("creating new ThresholdRule", slog.String("rule.id", id))
opts = append(opts, WithLogger(logger))
baseRule, err := NewBaseRule(id, orgID, p, opts...)
baseRule, err := NewBaseRule(id, orgID, p, externalURL, opts...)
if err != nil {
return nil, err
}
@@ -55,17 +56,6 @@ func NewThresholdRule(
}, nil
}
func (r *ThresholdRule) hostFromSource() string {
parsedURL, err := url.Parse(r.source)
if err != nil {
return ""
}
if parsedURL.Port() != "" {
return fmt.Sprintf("%s://%s:%s", parsedURL.Scheme, parsedURL.Hostname(), parsedURL.Port())
}
return fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Hostname())
}
func (r *ThresholdRule) Type() ruletypes.RuleType {
return ruletypes.RuleTypeThreshold
}
@@ -95,19 +85,19 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
return req, nil
}
func (r *ThresholdRule) prepareLinksToLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) string {
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
return nil
}
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
return ""
return nil
}
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
@@ -122,7 +112,7 @@ func (r *ThresholdRule) prepareLinksToLogs(ctx context.Context, ts time.Time, lb
}
if q.Signal != telemetrytypes.SignalLogs {
return ""
return nil
}
filterExpr := ""
@@ -132,22 +122,22 @@ func (r *ThresholdRule) prepareLinksToLogs(ctx context.Context, ts time.Time, lb
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
return contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
}
func (r *ThresholdRule) prepareLinksToTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) string {
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
return nil
}
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
return ""
return nil
}
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
@@ -162,7 +152,7 @@ func (r *ThresholdRule) prepareLinksToTraces(ctx context.Context, ts time.Time,
}
if q.Signal != telemetrytypes.SignalTraces {
return ""
return nil
}
filterExpr := ""
@@ -172,7 +162,7 @@ func (r *ThresholdRule) prepareLinksToTraces(ctx context.Context, ts time.Time,
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
return contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
@@ -349,16 +339,18 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
// label set, but different timestamps, together.
switch r.typ {
case ruletypes.AlertTypeTraces:
link := r.prepareLinksToTraces(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("traces-explorer", params)
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
}
case ruletypes.AlertTypeLogs:
link := r.prepareLinksToLogs(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
params := r.prepareParamsForLogs(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("logs/logs-explorer", params)
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
}
}

View File

@@ -69,7 +69,8 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
assert.NoError(t, err)
values := c.values
@@ -141,7 +142,7 @@ func TestNormalizeLabelName(t *testing.T) {
}
}
func TestPrepareLinksToLogs(t *testing.T) {
func TestPrepareParamsForLogs(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeLogs,
@@ -187,16 +188,20 @@ func TestPrepareLinksToLogs(t *testing.T) {
},
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
assert.NoError(t, err)
ts := time.UnixMilli(1705469040000)
link := rule.prepareLinksToLogs(context.Background(), ts, ruletypes.Labels{})
assert.Contains(t, link, "&timeRange=%7B%22start%22%3A1705468620000%2C%22end%22%3A1705468920000%2C%22pageSize%22%3A100%7D&startTime=1705468620000&endTime=1705468920000")
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000%2C%22end%22%3A1705468920000%2C%22pageSize%22%3A100%7D")
assert.Contains(t, params, "&startTime=1705468620000")
assert.Contains(t, params, "&endTime=1705468920000")
}
func TestPrepareLinksToLogsFilterExpression(t *testing.T) {
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeLogs,
@@ -246,16 +251,17 @@ func TestPrepareLinksToLogsFilterExpression(t *testing.T) {
},
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
assert.NoError(t, err)
ts := time.UnixMilli(1753527163000)
link := rule.prepareLinksToLogs(context.Background(), ts, ruletypes.Labels{})
assert.Contains(t, link, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522logs%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&timeRange=%7B%22start%22%3A1753526700000%2C%22end%22%3A1753527000000%2C%22pageSize%22%3A100%7D&startTime=1753526700000&endTime=1753527000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D")
params := rule.prepareParamsForLogs(context.Background(), ts, ruletypes.Labels{}).Encode()
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522logs%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000&timeRange=%7B%22start%22%3A1753526700000%2C%22end%22%3A1753527000000%2C%22pageSize%22%3A100%7D")
}
func TestPrepareLinksToTracesFilterExpression(t *testing.T) {
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertType: ruletypes.AlertTypeTraces,
@@ -305,16 +311,17 @@ func TestPrepareLinksToTracesFilterExpression(t *testing.T) {
},
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
assert.NoError(t, err)
ts := time.UnixMilli(1753527163000)
link := rule.prepareLinksToTraces(context.Background(), ts, ruletypes.Labels{})
assert.Contains(t, link, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522traces%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&timeRange=%7B%22start%22%3A1753526700000000000%2C%22end%22%3A1753527000000000000%2C%22pageSize%22%3A100%7D&startTime=1753526700000000000&endTime=1753527000000000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D")
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
assert.Contains(t, params, "compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522queryName%2522%253A%2522A%2522%252C%2522stepInterval%2522%253A60%252C%2522dataSource%2522%253A%2522traces%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522key%2522%253A%2522%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522limit%2522%253A0%252C%2522offset%2522%253A0%252C%2522pageSize%2522%253A0%252C%2522ShiftBy%2522%253A0%252C%2522IsAnomaly%2522%253Afalse%252C%2522QueriesUsedInFormula%2522%253Anull%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2BEXISTS%2522%257D%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%257D&endTime=1753527000000000000&options=%7B%22maxLines%22%3A0%2C%22format%22%3A%22%22%2C%22selectColumns%22%3Anull%7D&startTime=1753526700000000000&timeRange=%7B%22start%22%3A1753526700000000000%2C%22end%22%3A1753527000000000000%2C%22pageSize%22%3A100%7D")
}
func TestPrepareLinksToTraces(t *testing.T) {
func TestPrepareParamsForTraces(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Links to traces test",
AlertType: ruletypes.AlertTypeTraces,
@@ -360,15 +367,18 @@ func TestPrepareLinksToTraces(t *testing.T) {
},
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
if err != nil {
assert.NoError(t, err)
}
ts := time.UnixMilli(1705469040000)
link := rule.prepareLinksToTraces(context.Background(), ts, ruletypes.Labels{})
assert.Contains(t, link, "&timeRange=%7B%22start%22%3A1705468620000000000%2C%22end%22%3A1705468920000000000%2C%22pageSize%22%3A100%7D&startTime=1705468620000000000&endTime=1705468920000000000")
params := rule.prepareParamsForTraces(context.Background(), ts, ruletypes.Labels{}).Encode()
assert.Contains(t, params, "&timeRange=%7B%22start%22%3A1705468620000000000%2C%22end%22%3A1705468920000000000%2C%22pageSize%22%3A100%7D")
assert.Contains(t, params, "&startTime=1705468620000000000")
assert.Contains(t, params, "&endTime=1705468920000000000")
}
func TestThresholdRuleLabelNormalization(t *testing.T) {
@@ -444,7 +454,8 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
},
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
assert.NoError(t, err)
values := c.values
@@ -641,7 +652,8 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger)
externalURL := mustParseURL(t, "http://localhost:8000")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
if err != nil {
assert.NoError(t, err)
}
@@ -748,7 +760,8 @@ func TestThresholdRuleNoData(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger)
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
if err != nil {
assert.NoError(t, err)
@@ -853,7 +866,8 @@ func TestThresholdRuleTracesLink(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger)
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
if err != nil {
assert.NoError(t, err)
}
@@ -970,7 +984,8 @@ func TestThresholdRuleLogsLink(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger)
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
if err != nil {
assert.NoError(t, err)
}
@@ -1149,7 +1164,8 @@ func TestMultipleThresholdRule(t *testing.T) {
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger)
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
if err != nil {
assert.NoError(t, err)
@@ -1295,7 +1311,8 @@ func TestThresholdRuleEval_SendUnmatchedBypassesRecovery(t *testing.T) {
}
logger := instrumentationtest.New().Logger()
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
require.NoError(t, err)
now := time.Now()
@@ -1537,7 +1554,8 @@ func runEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases [
Spec: thresholds,
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
if err != nil {
assert.NoError(t, err)
return
@@ -1644,7 +1662,8 @@ func runMultiThresholdEvalTests(t *testing.T, postableRule ruletypes.PostableRul
Spec: thresholds,
}
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, WithEvalDelay(valuer.MustParseTextDuration("2m")))
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
if err != nil {
assert.NoError(t, err)
return
@@ -1931,6 +1950,7 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
&postableRule,
querier,
logger,
mustParseURL(t, "http://localhost:8080"),
)
require.NoError(t, err)
t.Run(fmt.Sprintf("%d, %s", idx, c.description), func(t *testing.T) {

View File

@@ -2,10 +2,7 @@ package ruletypes
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -191,35 +188,3 @@ func (rc *RuleCondition) String() string {
return string(data)
}
// PrepareRuleGeneratorURL creates an appropriate url for the rule. The URL is
// sent in Slack messages as well as to other systems and allows backtracking
// to the rule definition from the third party systems.
func PrepareRuleGeneratorURL(ruleID string, source string) string {
if source == "" {
return source
}
// check if source is a valid url
parsedSource, err := url.Parse(source)
if err != nil {
return ""
}
// since we capture window.location when a new rule is created
// we end up with rulesource host:port/alerts/new. in this case
// we want to replace new with rule id parameter
hasNew := strings.LastIndex(source, "new")
if hasNew > -1 {
ruleURL := fmt.Sprintf("%sedit?ruleId=%s", source[0:hasNew], ruleID)
return ruleURL
}
// The source contains the encoded query, start and end time
// and other parameters. We don't want to include them in the generator URL
// mainly to keep the URL short and lower the alert body contents
// The generator URL with /alerts/edit?ruleId= is enough
if parsedSource.Port() != "" {
return fmt.Sprintf("%s://%s:%s/alerts/edit?ruleId=%s", parsedSource.Scheme, parsedSource.Hostname(), parsedSource.Port(), ruleID)
}
return fmt.Sprintf("%s://%s/alerts/edit?ruleId=%s", parsedSource.Scheme, parsedSource.Hostname(), ruleID)
}

View File

@@ -1,81 +0,0 @@
package spantypes
import (
"maps"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type FlamegraphSpan struct {
SpanID string `json:"spanId"`
ParentSpanID string `json:"parentSpanId"`
Timestamp uint64 `json:"timestamp"`
DurationNano uint64 `json:"durationNano"`
HasError bool `json:"hasError"`
ServiceName string `json:"serviceName"`
Name string `json:"name"`
Level int64 `json:"level"`
Events []Event `json:"event"`
Attributes map[string]any `json:"attributes,omitempty"`
Resource map[string]string `json:"resource,omitempty"`
Children []*FlamegraphSpan `json:"-"` // internal tree use only
}
// FlamegraphLevel groups span IDs at a single level within the selected window.
type FlamegraphLevel struct {
Level int64
SpanIDs []string
}
type PostableFlamegraph struct {
SelectedSpanID string `json:"selectedSpanId"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
}
// GettableFlamegraphTrace is the response for the v3 flamegraph API.
type GettableFlamegraphTrace struct {
Spans [][]*FlamegraphSpan `json:"spans"`
StartTimestampMillis int64 `json:"startTimestampMillis"`
EndTimestampMillis int64 `json:"endTimestampMillis"`
HasMore bool `json:"hasMore"`
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
return &GettableFlamegraphTrace{
Spans: spans,
StartTimestampMillis: startMs,
EndTimestampMillis: endMs,
HasMore: hasMore,
}
}
func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64) *FlamegraphSpan {
resources := make(map[string]string, len(s.ResourcesString))
maps.Copy(resources, s.ResourcesString)
return &FlamegraphSpan{
SpanID: s.SpanID,
ParentSpanID: s.ParentSpanID,
Timestamp: uint64(s.StartTime.UnixNano()),
DurationNano: s.DurationNano,
HasError: s.HasError,
ServiceName: s.ServiceName,
Name: s.Name,
Level: level,
Events: s.UnmarshalledEvents(),
Attributes: s.Attributes(),
Resource: resources,
}
}
// FlamegraphWindowSpanIDs collects all span IDs from a level window into a flat slice.
func FlamegraphWindowSpanIDs(window []FlamegraphLevel) []string {
total := 0
for _, lvl := range window {
total += len(lvl.SpanIDs)
}
ids := make([]string, 0, total)
for _, lvl := range window {
ids = append(ids, lvl.SpanIDs...)
}
return ids
}

View File

@@ -1,279 +0,0 @@
package spantypes
import (
"sort"
)
// FlamegraphTrace holds the level wise tree built from minimal spans.
type FlamegraphTrace struct {
roots []*FlamegraphSpan
nodeByID map[string]*FlamegraphSpan
startTime uint64
endTime uint64
}
func NewFlamegraphTraceFromMinimal(spans []MinimalSpan) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := spans[i].ToFlamegraphSpan()
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.wireTree()
return t
}
func NewFlamegraphTraceFromStorable(spans []StorableSpan) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := NewFlamegraphSpanFromStorable(&spans[i], 0) // level is set later by BFS
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.wireTree()
return t
}
func (t *FlamegraphTrace) GetAllLevels() [][]*FlamegraphSpan {
allLevels := t.buildAllLevels()
for _, node := range t.nodeByID {
node.Children = nil // children not required after building tree
}
return allLevels
}
// GetSelectedLevels returns the level window for selectedSpanID with sampling applied to
// dense levels. It always applies windowing — callers should only invoke this when the
// trace is known to exceed the select-all limit.
// Children are cleared after traversal so the tree can be GC'd.
func (t *FlamegraphTrace) GetSelectedLevels(
selectedSpanID string,
levelLimit, spansPerLevel, topLatencyCount, bucketCount int,
) []FlamegraphLevel {
allLevels := t.buildAllLevels()
for _, node := range t.nodeByID {
node.Children = nil
}
selectedIndex := 0
if selectedSpanID != "" {
outer:
for i, lvl := range allLevels {
for _, span := range lvl {
if span.SpanID == selectedSpanID {
selectedIndex = i
break outer
}
}
}
}
lowerLimit := selectedIndex - int(float64(levelLimit)*0.4)
upperLimit := selectedIndex + int(float64(levelLimit)*0.6)
if lowerLimit < 0 {
upperLimit -= lowerLimit
lowerLimit = 0
}
if upperLimit > len(allLevels) {
lowerLimit -= upperLimit - len(allLevels)
upperLimit = len(allLevels)
}
if lowerLimit < 0 {
lowerLimit = 0
}
result := make([]FlamegraphLevel, 0, upperLimit-lowerLimit)
for i := lowerLimit; i < upperLimit; i++ {
lvl := allLevels[i]
if len(lvl) == 0 {
continue
}
var sampled []*FlamegraphSpan
if len(lvl) > spansPerLevel {
sampled = sampleFlamegraphLevel(lvl, selectedSpanID, i == selectedIndex,
t.startTime, t.endTime, topLatencyCount, bucketCount)
} else {
sampled = lvl
}
if len(sampled) == 0 {
continue
}
spanIDs := make([]string, len(sampled))
for j, s := range sampled {
spanIDs[j] = s.SpanID
}
result = append(result, FlamegraphLevel{
Level: sampled[0].Level,
SpanIDs: spanIDs,
})
}
return result
}
func (t *FlamegraphTrace) EnrichSelectedSpans(selectedSpans []FlamegraphLevel, fullSpans []StorableSpan) [][]*FlamegraphSpan {
fullByID := make(map[string]*StorableSpan, len(fullSpans))
for i := range fullSpans {
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
}
result := make([][]*FlamegraphSpan, len(selectedSpans))
for i, lvl := range selectedSpans {
result[i] = make([]*FlamegraphSpan, 0, len(lvl.SpanIDs))
for _, spanID := range lvl.SpanIDs {
if full, ok := fullByID[spanID]; ok {
result[i] = append(result[i], NewFlamegraphSpanFromStorable(full, lvl.Level))
} else if lean, ok := t.nodeByID[spanID]; ok {
result[i] = append(result[i], lean)
}
}
}
return result
}
func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
if t.startTime == 0 || timestamp < t.startTime {
t.startTime = timestamp
}
if end := timestamp + durationNano; end > t.endTime {
t.endTime = end
}
}
func (t *FlamegraphTrace) wireTree() {
for _, node := range t.nodeByID {
if node.ParentSpanID != "" {
if parent, ok := t.nodeByID[node.ParentSpanID]; ok {
parent.Children = append(parent.Children, node)
} else {
missing := &FlamegraphSpan{
SpanID: node.ParentSpanID,
Name: "Missing Span",
Timestamp: node.Timestamp,
DurationNano: node.DurationNano,
Children: []*FlamegraphSpan{node},
}
t.nodeByID[missing.SpanID] = missing
t.roots = append(t.roots, missing)
}
} else if flamegraphSpanIndex(t.roots, node.SpanID) == -1 {
t.roots = append(t.roots, node)
}
}
sort.Slice(t.roots, func(i, j int) bool {
if t.roots[i].Timestamp == t.roots[j].Timestamp {
return t.roots[i].SpanID < t.roots[j].SpanID
}
return t.roots[i].Timestamp < t.roots[j].Timestamp
})
}
func (t *FlamegraphTrace) buildAllLevels() [][]*FlamegraphSpan {
var result [][]*FlamegraphSpan
type entry struct {
node *FlamegraphSpan
depth int64
}
for _, root := range t.roots {
levelMap := make(map[int64][]*FlamegraphSpan)
maxDepth := int64(-1)
queue := []entry{{root, 0}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
curr.node.Level = curr.depth
levelMap[curr.depth] = append(levelMap[curr.depth], curr.node)
if curr.depth > maxDepth {
maxDepth = curr.depth
}
for _, child := range curr.node.Children {
queue = append(queue, entry{child, curr.depth + 1})
}
}
for depth := int64(0); depth <= maxDepth; depth++ {
if spans, ok := levelMap[depth]; ok {
result = append(result, spans)
}
}
}
return result
}
func sampleFlamegraphLevel(
spans []*FlamegraphSpan,
selectedSpanID string,
isSelectedLevel bool,
startTime, endTime uint64,
topLatencyCount, bucketCount int,
) []*FlamegraphSpan {
sorted := make([]*FlamegraphSpan, len(spans))
copy(sorted, spans)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].DurationNano > sorted[j].DurationNano
})
var sampled []*FlamegraphSpan
topK := topLatencyCount
if topK > len(sorted) {
topK = len(sorted)
}
sampled = append(sampled, sorted[:topK]...)
if isSelectedLevel {
for _, span := range sorted {
if span.SpanID == selectedSpanID {
sampled = append(sampled, span)
break
}
}
}
bucketSize := (endTime - startTime) / uint64(bucketCount)
if bucketSize == 0 {
bucketSize = 1
}
buckets := make([][]*FlamegraphSpan, bucketCount)
for _, span := range sorted {
if span.Timestamp < startTime || span.Timestamp > endTime {
continue
}
idx := int((span.Timestamp - startTime) / bucketSize)
if idx < 0 {
idx = 0
} else if idx >= bucketCount {
idx = bucketCount - 1
}
buckets[idx] = append(buckets[idx], span)
}
for i := range buckets {
if len(buckets[i]) > 2 {
buckets[i] = buckets[i][:2]
}
}
for _, bucket := range buckets {
sampled = append(sampled, bucket...)
}
return sampled
}
func flamegraphSpanIndex(spans []*FlamegraphSpan, spanID string) int {
for i, s := range spans {
if s != nil && s.SpanID == spanID {
return i
}
}
return -1
}

View File

@@ -156,18 +156,6 @@ func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
}
}
func (item *MinimalSpan) ToFlamegraphSpan() *FlamegraphSpan {
return &FlamegraphSpan{
SpanID: item.SpanID,
ParentSpanID: item.ParentSpanID,
Timestamp: uint64(item.StartTime.UnixNano()),
DurationNano: item.DurationNano,
HasError: item.HasError,
ServiceName: item.ServiceName,
Children: make([]*FlamegraphSpan, 0),
}
}
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
return &WaterfallSpan{