Compare commits

...

9 Commits

Author SHA1 Message Date
srikanthccv
72eae28a33 Merge branch 'fix/panic-fixes' of github.com:SigNoz/signoz into fix/panic-fixes 2026-04-03 08:48:34 +05:30
srikanthccv
770fad4b0d fix: add recover for dashboard sql 2026-04-03 08:48:13 +05:30
Srikanth Chekuri
f1c81f9708 Merge branch 'main' into fix/panic-fixes 2026-04-03 07:23:23 +05:30
srikanthccv
0a44346ee9 fix: several panic errors 2026-04-03 07:14:57 +05:30
Vinicius Lourenço
419bd60a41 feat(global-time-adapter): start migration away from redux (#10780)
Some checks failed
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
* feat(global-time-adapter): start migration away from redux

* test(constant): add missing constants

* refactor(hooks): move to hooks folder
2026-04-02 11:17:49 +00:00
SagarRajput-7
b6b689902d feat: added doc links for service account and misc changes (#10804)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: added doc links for service account and misc changes

* feat: remove announcement and presisted announcement banner, since we app this in periscope library

* feat: updated banner text
2026-04-02 08:22:31 +00:00
Naman Verma
65402ca367 fix: warning instead of error for dormant metrics in query range API (#10737)
* fix: warning instead of error for dormant metrics in query range API

* fix: add missing else

* fix: keep track of present aggregations

* fix: note present aggregation after type is set

* test: integration test fix and new test

* chore: lint errors

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 07:25:29 +00:00
Tushar Vats
f71d5bf8f1 fix: added validations for having expression (#10286)
* fix: added validations for having expression

* fix: added extra validation and unit tests

* fix: added antlr based parsing for validation

* fix: added more unit tests

* fix: removed validation on having in range request validations

* fix: generated lexer files and added more unit tests

* fix: edge cases

* fix: added cmnd to scripts for generating lexer

* fix: use std libg sorting instead of selection sort

* fix: support implicit and

* fix: allow bare not in expression

* fix: added suggestion for having expression

* fix: typo

* fix: added more unit tests, handle white space difference in aggregation exp and having exp

* fix: added support for in and not, updated errors

* fix: added support for brackets list

* fix: lint error

* fix: handle non spaced expression

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-02 03:52:11 +00:00
Srikanth Chekuri
5abfd0732a chore: remove deprecated v3/v4 support in rules (#10760)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove deprecated v3/v4 support in rules

* chore: fix test

* chore: fix logs

* chore: fix logging

* chore: fix ci

* chore: address review comments
2026-04-01 19:48:37 +00:00
134 changed files with 12072 additions and 5032 deletions

View File

@@ -2313,15 +2313,6 @@ components:
- status
- error
type: object
RulestatehistorytypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
RulestatehistorytypesGettableRuleStateHistory:
properties:
fingerprint:
@@ -2333,15 +2324,15 @@ components:
nullable: true
type: array
overallState:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
ruleID:
ruleId:
type: string
ruleName:
type: string
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
stateChanged:
type: boolean
unixMilli:
@@ -2351,7 +2342,7 @@ components:
format: double
type: number
required:
- ruleID
- ruleId
- ruleName
- overallState
- overallStateChanged
@@ -2441,12 +2432,21 @@ components:
format: int64
type: integer
state:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
required:
- state
- start
- end
type: object
RuletypesAlertState:
enum:
- inactive
- pending
- recovering
- firing
- nodata
- disabled
type: string
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -8469,7 +8469,7 @@ paths:
- in: query
name: state
schema:
$ref: '#/components/schemas/RulestatehistorytypesAlertState'
$ref: '#/components/schemas/RuletypesAlertState'
- in: query
name: filterExpression
schema:

View File

@@ -32,7 +32,7 @@ func (s Seasonality) IsValid() bool {
}
type AnomaliesRequest struct {
Params qbtypes.QueryRangeRequest
Params *qbtypes.QueryRangeRequest
Seasonality Seasonality
}
@@ -81,7 +81,7 @@ type anomalyQueryParams struct {
Past3SeasonQuery qbtypes.QueryRangeRequest
}
func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams {
start := req.Start
end := req.End

View File

@@ -65,7 +65,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok {
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest)
anomalies, err := h.handleAnomalyQuery(ctx, orgID, anomalyQuery, &queryRangeRequest)
if err != nil {
render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err))
return
@@ -149,7 +149,7 @@ func (h *handler) createAnomalyProvider(seasonality anomalyV2.Seasonality) anoma
}
}
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
func (h *handler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest *qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) {
seasonality := extractSeasonality(anomalyQuery)
provider := h.createAnomalyProvider(seasonality)

View File

@@ -49,7 +49,6 @@ import (
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
@@ -99,7 +98,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
rm, err := makeRulesManager(
reader,
signoz.Cache,
signoz.Alertmanager,
signoz.SQLStore,
@@ -345,7 +343,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
@@ -354,7 +352,6 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -365,7 +362,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
SQLStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -5,58 +5,34 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
"github.com/SigNoz/signoz/ee/anomaly"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
const (
RuleTypeAnomaly = "anomaly_rule"
)
type AnomalyRule struct {
*baserules.BaseRule
mtx sync.Mutex
reader interfaces.Reader
// querier is used for alerts migrated after the introduction of new query builder
querier querier.Querier
// querierV2 is used for alerts created after the introduction of new metrics query builder
querierV2 interfaces.Querier
// querierV5 is used for alerts migrated after the introduction of new query builder
querierV5 querierV5.Querier
provider anomaly.Provider
providerV2 anomalyV2.Provider
provider anomaly.Provider
version string
logger *slog.Logger
@@ -70,18 +46,16 @@ func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
reader interfaces.Reader,
querierV5 querierV5.Querier,
querier querier.Querier,
logger *slog.Logger,
cache cache.Cache,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", "rule_id", id)
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
opts = append(opts, baserules.WithLogger(logger))
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
if err != nil {
return nil, err
}
@@ -101,93 +75,38 @@ func NewAnomalyRule(
t.seasonality = anomaly.SeasonalityDaily
}
logger.Info("using seasonality", "seasonality", t.seasonality.String())
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
t.reader = reader
if t.seasonality == anomaly.SeasonalityHourly {
t.provider = anomaly.NewHourlyProvider(
anomaly.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
)
}
if t.seasonality == anomaly.SeasonalityHourly {
t.providerV2 = anomalyV2.NewHourlyProvider(
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.providerV2 = anomalyV2.NewDailyProvider(
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.providerV2 = anomalyV2.NewWeeklyProvider(
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
)
}
t.querierV5 = querierV5
t.querier = querier
t.version = p.Version
t.logger = logger
return &t, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
return RuleTypeAnomaly
return ruletypes.RuleTypeAnomaly
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
)
st, en := r.Timestamps(ts)
start := st.UnixMilli()
end := en.UnixMilli()
compositeQuery := r.Condition().CompositeQuery
if compositeQuery.PanelType != v3.PanelTypeGraph {
compositeQuery.PanelType = v3.PanelTypeGraph
}
// default mode
return &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: compositeQuery,
Variables: make(map[string]interface{}, 0),
NoCache: false,
}, nil
}
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
r.logger.InfoContext(ctx, "prepare query range request", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()), slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()), slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()))
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
@@ -203,25 +122,14 @@ func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*q
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req, nil
}
func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
return req
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
params := r.prepareQueryRange(ctx, ts)
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.AnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -229,87 +137,43 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
return nil, err
}
var queryResult *v3.Result
var queryResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
if result.QueryName == r.SelectedQuery(ctx) {
queryResult = result
break
}
}
hasData := len(queryResult.AnomalyScores) > 0
if queryResult == nil {
r.logger.WarnContext(ctx, "nil qb result", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()))
return ruletypes.Vector{}, nil
}
hasData := len(queryResult.Aggregations) > 0 &&
queryResult.Aggregations[0] != nil &&
len(queryResult.Aggregations[0].AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
} else if !hasData {
r.logger.WarnContext(ctx, "no anomaly result", slog.String("rule.id", r.ID()))
return ruletypes.Vector{}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
for _, series := range queryResult.AnomalyScores {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
}
resultVector = append(resultVector, results...)
}
return resultVector, nil
}
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRangeV5(ctx, ts)
if err != nil {
return nil, err
}
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
Params: *params,
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
})
if err != nil {
return nil, err
}
var qbResult *qbtypes.TimeSeriesData
for _, result := range anomalies.Results {
if result.QueryName == r.GetSelectedQuery() {
qbResult = result
break
}
}
if qbResult == nil {
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
}
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
hasData := len(queryResult.AnomalyScores) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
r.logger.InfoContext(ctx, "anomaly scores", slog.String("rule.id", r.ID()), slog.String("anomaly.scores", string(scoresJSON)))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.AnomalyScores
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
if r.ShouldSkipNewGroups() {
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -317,10 +181,10 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -341,13 +205,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
if r.version == "v5" {
r.logger.InfoContext(ctx, "running v5 query")
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
} else {
r.logger.InfoContext(ctx, "running v4 query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
}
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if err != nil {
return 0, err
}
@@ -371,7 +231,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -386,35 +246,34 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData, "rule_name", r.Name())
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
for name, value := range r.Labels().Map() {
lb.Set(name, expand(value))
}
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(labels.Labels, 0, len(r.Annotations().Map()))
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -422,17 +281,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.String("rule.id", r.ID()), slog.Any("alert", alerts[h]))
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
QueryResultLabels: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: model.StatePending,
State: ruletypes.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -441,12 +300,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -462,76 +321,76 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []model.RuleStateHistory{}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLables)
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != model.StateInactive {
a.State = model.StateInactive
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: model.StateInactive,
State: ruletypes.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := model.StateFiring
state := ruletypes.StateFiring
if a.Missing {
state = model.StateNoData
state = ruletypes.StateNoData
}
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
changeFiringToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
state := ruletypes.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}

View File

@@ -2,21 +2,19 @@ package rules
import (
"context"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/ee/anomaly"
)
// mockAnomalyProvider is a mock implementation of anomaly.Provider for testing.
@@ -24,13 +22,13 @@ import (
// time periods (current, past period, current season, past season, past 2 seasons,
// past 3 seasons), making it cumbersome to create mock data.
type mockAnomalyProvider struct {
responses []*anomaly.GetAnomaliesResponse
responses []*anomaly.AnomaliesResponse
callCount int
}
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.GetAnomaliesRequest) (*anomaly.GetAnomaliesResponse, error) {
func (m *mockAnomalyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *anomaly.AnomaliesRequest) (*anomaly.AnomaliesResponse, error) {
if m.callCount >= len(m.responses) {
return &anomaly.GetAnomaliesResponse{Results: []*v3.Result{}}, nil
return &anomaly.AnomaliesResponse{Results: []*qbtypes.TimeSeriesData{}}, nil
}
resp := m.responses[m.callCount]
m.callCount++
@@ -49,45 +47,46 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
},
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -115,23 +114,17 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
t.Run(c.description, func(t *testing.T) {
postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule(
"test-anomaly-rule",
valuer.GenerateUUID(),
&postableRule,
reader,
nil,
logger,
nil,
)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseNoData},
responses: []*anomaly.AnomaliesResponse{responseNoData},
}
alertsFound, err := rule.Eval(context.Background(), evalTime)
@@ -156,46 +149,47 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
Expression: "A",
DataSource: v3.DataSourceMetrics,
Temporality: v3.Unspecified,
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
},
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "Test anomaly no data with AbsentFor",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
}
responseNoData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseNoData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{},
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{},
}},
},
},
}
@@ -229,32 +223,35 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
t1 := baseTime.Add(5 * time.Minute)
t2 := t1.Add(c.timeBetweenEvals)
responseWithData := &anomaly.GetAnomaliesResponse{
Results: []*v3.Result{
responseWithData := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
AnomalyScores: []*v3.Series{
{
Labels: map[string]string{"test": "label"},
Points: []v3.Point{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "Test"},
Value: "labels",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.UnixMilli(), Value: 1.0},
{Timestamp: baseTime.Add(time.Minute).UnixMilli(), Value: 1.5},
},
},
},
},
}},
},
},
}
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, nil)
options := clickhouseReader.NewOptions("primaryNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, nil, "", time.Second, nil, nil, options)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, reader, nil, logger, nil)
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{
responses: []*anomaly.GetAnomaliesResponse{responseWithData, responseNoData},
responses: []*anomaly.AnomaliesResponse{responseWithData, responseNoData},
}
alertsFound1, err := rule.Eval(context.Background(), t1)

View File

@@ -11,9 +11,7 @@ import (
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/errors"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -23,7 +21,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules := make([]baserules.Rule, 0)
var task baserules.Task
ruleId := baserules.RuleIdFromTaskName(opts.TaskName)
ruleID := baserules.RuleIDFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
@@ -32,10 +30,9 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -58,11 +55,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create promql rule
pr, err := baserules.NewPromRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -82,13 +78,11 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
ar, err := NewAnomalyRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -105,7 +99,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -113,12 +107,12 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.ApiError) {
func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
ctx := context.Background()
if opts.Rule == nil {
return 0, basemodel.BadRequest(fmt.Errorf("rule is required"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
}
parsedRule := opts.Rule
@@ -138,15 +132,14 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
baserules.WithSendAlways(),
@@ -158,7 +151,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -169,7 +162,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
@@ -180,7 +172,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if err != nil {
slog.Error("failed to prepare a new promql rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -188,10 +180,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
opts.Cache,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),
@@ -200,10 +190,10 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
slog.Error("failed to prepare a new anomaly rule for test", "name", alertname, errors.Attr(err))
return 0, basemodel.BadRequest(err)
return 0, err
}
} else {
return 0, basemodel.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to derive ruletype with given information")
}
// set timestamp to current utc time
@@ -212,7 +202,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, basemodel.InternalError(fmt.Errorf("rule evaluation failed"))
return 0, err
}
rule.SendAlerts(ctx, ts, 0, time.Minute, opts.NotifyFunc)

View File

@@ -114,11 +114,8 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -268,11 +265,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {

View File

@@ -2710,14 +2710,6 @@ export interface RenderErrorResponseDTO {
status: string;
}
export enum RulestatehistorytypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type integer
@@ -2729,7 +2721,7 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @nullable true
*/
labels: Querybuildertypesv5LabelDTO[] | null;
overallState: RulestatehistorytypesAlertStateDTO;
overallState: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2737,12 +2729,12 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
/**
* @type string
*/
ruleID: string;
ruleId: string;
/**
* @type string
*/
ruleName: string;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
/**
* @type boolean
*/
@@ -2840,9 +2832,17 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
* @format int64
*/
start: number;
state: RulestatehistorytypesAlertStateDTO;
state: RuletypesAlertStateDTO;
}
export enum RuletypesAlertStateDTO {
inactive = 'inactive',
pending = 'pending',
recovering = 'recovering',
firing = 'firing',
nodata = 'nodata',
disabled = 'disabled',
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -4613,7 +4613,7 @@ export type GetRuleHistoryTimelineParams = {
/**
* @description undefined
*/
state?: RulestatehistorytypesAlertStateDTO;
state?: RuletypesAlertStateDTO;
/**
* @type string
* @description undefined

View File

@@ -1,97 +0,0 @@
.announcement-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--padding-2) var(--padding-4);
height: 40px;
font-family: var(--font-sans), sans-serif;
font-size: var(--label-base-500-font-size);
line-height: var(--label-base-500-line-height);
font-weight: var(--label-base-500-font-weight);
letter-spacing: -0.065px;
&--warning {
background-color: var(--callout-warning-background);
color: var(--callout-warning-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-warning-border);
}
}
&--info {
background-color: var(--callout-primary-background);
color: var(--callout-primary-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-primary-border);
}
}
&--error {
background-color: var(--callout-error-background);
color: var(--callout-error-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-error-border);
}
}
&--success {
background-color: var(--callout-success-background);
color: var(--callout-success-description);
.announcement-banner__action,
.announcement-banner__dismiss {
background: var(--callout-success-border);
}
}
&__body {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex: 1;
min-width: 0;
}
&__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height-normal);
strong {
font-weight: var(--font-weight-semibold);
}
}
&__action {
height: 24px;
font-size: var(--label-small-500-font-size);
color: currentColor;
&:hover {
opacity: 0.8;
}
}
&__dismiss {
width: 24px;
height: 24px;
padding: 0;
color: currentColor;
&:hover {
opacity: 0.8;
}
}
}

View File

@@ -1,89 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import {
AnnouncementBanner,
AnnouncementBannerProps,
PersistedAnnouncementBanner,
} from './index';
const STORAGE_KEY = 'test-banner-dismissed';
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
render(<AnnouncementBanner message="Test message" {...props} />);
}
afterEach(() => {
localStorage.removeItem(STORAGE_KEY);
});
describe('AnnouncementBanner', () => {
it('renders message and default warning variant', () => {
renderBanner({ message: <strong>Heads up</strong> });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass('announcement-banner--warning');
expect(alert).toHaveTextContent('Heads up');
});
it.each(['warning', 'info', 'success', 'error'] as const)(
'renders %s variant correctly',
(type) => {
renderBanner({ type, message: 'Test message' });
const alert = screen.getByRole('alert');
expect(alert).toHaveClass(`announcement-banner--${type}`);
},
);
it('calls action onClick when action button is clicked', async () => {
const onClick = jest.fn() as jest.MockedFunction<() => void>;
renderBanner({ action: { label: 'Go to Settings', onClick } });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /go to settings/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
renderBanner({ onClose: undefined, icon: null });
expect(
screen.queryByRole('button', { name: /dismiss/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
).not.toBeInTheDocument();
});
});
describe('PersistedAnnouncementBanner', () => {
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
onDismiss={onDismiss}
/>,
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
});
it('does not render when storageKey is already set in localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'true');
render(
<PersistedAnnouncementBanner
message="Test message"
storageKey={STORAGE_KEY}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

View File

@@ -1,84 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import {
CircleAlert,
CircleCheckBig,
Info,
TriangleAlert,
X,
} from '@signozhq/icons';
import cx from 'classnames';
import './AnnouncementBanner.styles.scss';
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
export interface AnnouncementBannerAction {
label: string;
onClick: () => void;
}
export interface AnnouncementBannerProps {
message: ReactNode;
type?: AnnouncementBannerType;
icon?: ReactNode | null;
action?: AnnouncementBannerAction;
onClose?: () => void;
className?: string;
}
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
warning: <TriangleAlert size={14} />,
info: <Info size={14} />,
error: <CircleAlert size={14} />,
success: <CircleCheckBig size={14} />,
};
export default function AnnouncementBanner({
message,
type = 'warning',
icon,
action,
onClose,
className,
}: AnnouncementBannerProps): JSX.Element {
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
return (
<div
role="alert"
className={cx(
'announcement-banner',
`announcement-banner--${type}`,
className,
)}
>
<div className="announcement-banner__body">
{resolvedIcon && (
<span className="announcement-banner__icon">{resolvedIcon}</span>
)}
<span className="announcement-banner__message">{message}</span>
{action && (
<Button
type="button"
className="announcement-banner__action"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
{onClose && (
<Button
type="button"
aria-label="Dismiss"
className="announcement-banner__dismiss"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useState } from 'react';
import AnnouncementBanner, {
AnnouncementBannerProps,
} from './AnnouncementBanner';
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
storageKey: string;
onDismiss?: () => void;
}
function isDismissed(storageKey: string): boolean {
return localStorage.getItem(storageKey) === 'true';
}
export default function PersistedAnnouncementBanner({
storageKey,
onDismiss,
...props
}: PersistedAnnouncementBannerProps): JSX.Element | null {
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
if (!visible) {
return null;
}
const handleClose = (): void => {
localStorage.setItem(storageKey, 'true');
setVisible(false);
onDismiss?.();
};
return <AnnouncementBanner {...props} onClose={handleClose} />;
}

View File

@@ -1,12 +0,0 @@
import AnnouncementBanner from './AnnouncementBanner';
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
export type {
AnnouncementBannerAction,
AnnouncementBannerProps,
AnnouncementBannerType,
} from './AnnouncementBanner';
export { AnnouncementBanner, PersistedAnnouncementBanner };
export default AnnouncementBanner;

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { refreshIntervalOptions } from 'container/TopNav/AutoRefreshV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Adapter component that syncs Redux global time state to Zustand store.
* This component should be rendered once at the app level.
*
* It reads from the Redux globalTime reducer and updates the Zustand store
* to provide a migration path from Redux to Zustand.
*/
export function GlobalTimeStoreAdapter(): null {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const setSelectedTime = useGlobalTimeStore((s) => s.setSelectedTime);
useEffect(() => {
// Convert the selectedTime to the new format
// If it's 'custom', store the min/max times in the custom format
const selectedTime =
globalTime.selectedTime === 'custom'
? createCustomTimeRange(globalTime.minTime, globalTime.maxTime)
: (globalTime.selectedTime as Time);
// Find refresh interval from Redux state
const refreshOption = refreshIntervalOptions.find(
(option) => option.key === globalTime.selectedAutoRefreshInterval,
);
const refreshInterval =
!globalTime.isAutoRefreshDisabled && refreshOption ? refreshOption.value : 0;
setSelectedTime(selectedTime, refreshInterval);
}, [
globalTime.selectedTime,
globalTime.isAutoRefreshDisabled,
globalTime.selectedAutoRefreshInterval,
globalTime.minTime,
globalTime.maxTime,
setSelectedTime,
]);
return null;
}

View File

@@ -0,0 +1,227 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { act, render, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { useGlobalTimeStore } from 'store/globalTime/globalTimeStore';
import { createCustomTimeRange } from 'store/globalTime/utils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { GlobalTimeStoreAdapter } from '../GlobalTimeStoreAdapter';
const mockStore = configureStore<Partial<AppState>>([]);
const randomTime = 1700000000000000000;
describe('GlobalTimeStoreAdapter', () => {
let store: MockStoreEnhanced<Partial<AppState>>;
const createGlobalTimeState = (
overrides: Partial<GlobalReducer> = {},
): GlobalReducer => ({
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
...overrides,
});
beforeEach(() => {
// Reset Zustand store before each test
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
it('should render null because it just an adapter', () => {
store = mockStore({
globalTime: createGlobalTimeState(),
});
const { container } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
expect(container.firstChild).toBeNull();
});
it('should sync relative time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: 'off',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync custom time from Redux to Zustand store', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: 'custom',
minTime: randomTime,
maxTime: randomTime,
isAutoRefreshDisabled: true,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(
createCustomTimeRange(randomTime, randomTime),
);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should sync refresh interval when auto refresh is enabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe('15m');
expect(result.current.refreshInterval).toBe(5000); // 5s = 5000ms
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should set refreshInterval to 0 when auto refresh is disabled', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
selectedAutoRefreshInterval: '5s', // Even with interval set, should be 0 when disabled
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should update Zustand store when Redux state changes', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: true,
}),
});
const { rerender } = render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify initial state
let zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('15m');
// Update Redux store
const newStore = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '1h',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '30s',
}),
});
rerender(
<Provider store={newStore}>
<GlobalTimeStoreAdapter />
</Provider>,
);
// Verify updated state
zustandState = renderHook(() => useGlobalTimeStore());
expect(zustandState.result.current.selectedTime).toBe('1h');
expect(zustandState.result.current.refreshInterval).toBe(30000); // 30s = 30000ms
expect(zustandState.result.current.isRefreshEnabled).toBe(true);
});
it('should handle various refresh interval options', () => {
const testCases = [
{ key: '5s', expectedValue: 5000 },
{ key: '10s', expectedValue: 10000 },
{ key: '30s', expectedValue: 30000 },
{ key: '1m', expectedValue: 60000 },
{ key: '5m', expectedValue: 300000 },
];
testCases.forEach(({ key, expectedValue }) => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: key,
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(expectedValue);
});
});
it('should handle unknown refresh interval by setting 0', () => {
store = mockStore({
globalTime: createGlobalTimeState({
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: 'unknown-interval',
}),
});
render(
<Provider store={store}>
<GlobalTimeStoreAdapter />
</Provider>,
);
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
expect(result.current.isRefreshEnabled).toBe(false);
});
});

View File

@@ -165,7 +165,17 @@ function KeysTab({
return (
<div className="keys-tab__empty">
<KeyRound size={24} className="keys-tab__empty-icon" />
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<p className="keys-tab__empty-text">
No keys. Start by creating one.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts/#step-3-generate-an-api-key"
target="_blank"
rel="noopener noreferrer"
className="keys-tab__learn-more"
>
Learn more
</a>
</p>
<Button
type="button"
className="keys-tab__learn-more"

View File

@@ -1,4 +1,11 @@
export const REACT_QUERY_KEY = {
/**
* For any query that should support AutoRefresh and min/max time is from DateTimeSelectionV2
* You can prefix the query with this KEY, it will allow the queries to be automatically refreshed
* when the user clicks in the refresh button, or alert the user when the data is being refreshed.
*/
AUTO_REFRESH_QUERY: 'AUTO_REFRESH_QUERY',
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',

View File

@@ -248,5 +248,35 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.BILLING),
},
{
id: 'my-settings-service-accounts',
name: 'Go to Service Accounts',
shortcut: [GlobalShortcutsName.NavigateToSettingsServiceAccounts],
keywords: 'settings service accounts',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
},
{
id: 'my-settings-roles',
name: 'Go to Roles',
shortcut: [GlobalShortcutsName.NavigateToSettingsRoles],
keywords: 'settings roles',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.ROLES_SETTINGS),
},
{
id: 'my-settings-members',
name: 'Go to Members',
shortcut: [GlobalShortcutsName.NavigateToSettingsMembers],
keywords: 'settings members',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN'],
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
},
];
}

View File

@@ -27,6 +27,9 @@ export const GlobalShortcuts = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
};
export const GlobalShortcutsName = {
@@ -47,6 +50,9 @@ export const GlobalShortcutsName = {
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
@@ -74,4 +80,7 @@ export const GlobalShortcutsDescription = {
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',
NavigateToSettingsMembers: 'Navigate to Members Settings',
};

View File

@@ -3,12 +3,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { PersistedAnnouncementBanner } from '@signozhq/ui';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -265,20 +265,19 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<PersistedAnnouncementBanner
type="warning"
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
message={
<>
<strong>API Keys</strong> have been deprecated and replaced by{' '}
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
programmatic API access.
</>
}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
/>
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been migrated
to service accounts.
</>
</PersistedAnnouncementBanner>
<div className="sticky-header">
<Header

View File

@@ -198,15 +198,14 @@ function ServiceAccountsSettings(): JSX.Element {
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Overview of service accounts added to this workspace.{' '}
{/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
<a
href="https://signoz.io/docs/manage/administrator-guide/iam/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a> */}
</a>
</p>
</div>

View File

@@ -695,6 +695,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
onClickHandler(ROUTES.SERVICE_ACCOUNTS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsRoles, () =>
onClickHandler(ROUTES.ROLES_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsMembers, () =>
onClickHandler(ROUTES.MEMBERS_SETTINGS, null),
);
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
onClickHandler(ROUTES.LOGS_PIPELINES, null),
);
@@ -718,6 +727,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);

View File

@@ -14,6 +14,10 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import NewExplorerCTA from 'container/NewExplorerCTA';
import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -352,7 +356,10 @@ function DateTimeSelection({
],
);
const isRefreshingQueries = useIsGlobalTimeQueryRefreshing();
const invalidateQueries = useGlobalTimeQueryInvalidate();
const onRefreshHandler = (): void => {
invalidateQueries();
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
@@ -732,7 +739,11 @@ function DateTimeSelection({
{showAutoRefresh && selectedTime !== 'custom' && (
<div className="refresh-actions">
<FormItem hidden={refreshButtonHidden} className="refresh-btn">
<Button icon={<SyncOutlined />} onClick={onRefreshHandler} />
<Button
icon={<SyncOutlined />}
loading={!!isRefreshingQueries}
onClick={onRefreshHandler}
/>
</FormItem>
<FormItem>

View File

@@ -0,0 +1,2 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -0,0 +1,16 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -0,0 +1,13 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
return (
useIsFetching({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
}) > 0
);
}

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
@@ -51,6 +52,7 @@ if (container) {
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<GlobalTimeStoreAdapter />
<AppProvider>
<AppRoutes />
</AppProvider>

View File

@@ -143,7 +143,9 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_DETAILS
? true
: item.isEnabled,
}));

View File

@@ -62,12 +62,16 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...membersSettings(t), ...serviceAccountsSettings(t));
settings.push(
...membersSettings(t),
...serviceAccountsSettings(t),
...rolesSettings(t),
...roleDetails(t),
);
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
settings.push(...billingSettings(t), ...rolesSettings(t), ...roleDetails(t));
settings.push(...billingSettings(t));
}
settings.push(

View File

@@ -0,0 +1,204 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const {
minTime: resultMin,
maxTime: resultMax,
} = result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,139 @@
import {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
} from '../utils';
describe('globalTime/utils', () => {
describe('CUSTOM_TIME_SEPARATOR', () => {
it('should be defined as ||_||', () => {
expect(CUSTOM_TIME_SEPARATOR).toBe('||_||');
});
});
describe('isCustomTimeRange', () => {
it('should return true for custom time range strings', () => {
expect(isCustomTimeRange('1000000000||_||2000000000')).toBe(true);
expect(isCustomTimeRange('0||_||0')).toBe(true);
});
it('should return false for relative time strings', () => {
expect(isCustomTimeRange('15m')).toBe(false);
expect(isCustomTimeRange('1h')).toBe(false);
expect(isCustomTimeRange('1d')).toBe(false);
expect(isCustomTimeRange('30s')).toBe(false);
});
it('should return false for empty string', () => {
expect(isCustomTimeRange('')).toBe(false);
});
});
describe('createCustomTimeRange', () => {
it('should create a custom time range string from min and max times', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const result = createCustomTimeRange(minTime, maxTime);
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
});
it('should handle zero values', () => {
const result = createCustomTimeRange(0, 0);
expect(result).toBe(`0${CUSTOM_TIME_SEPARATOR}0`);
});
it('should handle large nanosecond timestamps', () => {
const minTime = 1700000000000000000;
const maxTime = 1700000001000000000;
const result = createCustomTimeRange(minTime, maxTime);
expect(result).toBe(`${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`);
});
});
describe('parseCustomTimeRange', () => {
it('should parse a valid custom time range string', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
const result = parseCustomTimeRange(timeString);
expect(result).toEqual({ minTime, maxTime });
});
it('should return null for non-custom time range strings', () => {
expect(parseCustomTimeRange('15m')).toBeNull();
expect(parseCustomTimeRange('1h')).toBeNull();
});
it('should return null for invalid numeric values', () => {
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
expect(parseCustomTimeRange(`123${CUSTOM_TIME_SEPARATOR}def`)).toBeNull();
expect(parseCustomTimeRange(`abc${CUSTOM_TIME_SEPARATOR}456`)).toBeNull();
});
it('should handle zero values', () => {
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
expect(result).toEqual({ minTime: 0, maxTime: 0 });
});
});
describe('parseSelectedTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should parse custom time range and return min/max values', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const timeString = createCustomTimeRange(minTime, maxTime);
const result = parseSelectedTime(timeString);
expect(result).toEqual({ minTime, maxTime });
});
it('should return fallback for invalid custom time range', () => {
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
const result = parseSelectedTime(invalidCustom);
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fallbackDuration = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s in nanoseconds
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fallbackDuration);
});
it('should parse relative time strings using getMinMaxForSelectedTime', () => {
const result = parseSelectedTime('15m');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 15 minutes in nanoseconds
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fifteenMinutesNs);
});
it('should parse 1h relative time', () => {
const result = parseSelectedTime('1h');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 1 hour in nanoseconds
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - oneHourNs);
});
it('should parse 1d relative time', () => {
const result = parseSelectedTime('1d');
const now = Date.now() * NANO_SECOND_MULTIPLIER;
// 1 day in nanoseconds
const oneDayNs = 24 * 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - oneDayNs);
});
});
});

View File

@@ -0,0 +1,33 @@
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (): ParsedTimeRange => {
const { selectedTime } = get();
return parseSelectedTime(selectedTime);
},
}));

View File

@@ -0,0 +1,9 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
export {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
isCustomTimeRange,
parseCustomTimeRange,
parseSelectedTime,
} from './utils';

View File

@@ -0,0 +1,52 @@
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
export type CustomTimeRangeSeparator = '||_||';
export type CustomTimeRange = `${number}${CustomTimeRangeSeparator}${number}`;
export type GlobalTimeSelectedTime = Time | CustomTimeRange;
export interface IGlobalTimeStoreState {
/**
* The selected time range, can be:
* - Relative duration: '1m', '5m', '15m', '1h', '1d', etc.
* - Custom range: '<minTimeUnixNano>||_||<maxTimeUnixNano>' format
*/
selectedTime: GlobalTimeSelectedTime;
/**
* Whether auto-refresh is enabled.
* Automatically computed: true for duration-based times, false for custom ranges.
*/
isRefreshEnabled: boolean;
/**
* The refresh interval in milliseconds (e.g., 5000 for 5s, 30000 for 30s)
* Only used when isRefreshEnabled is true
*/
refreshInterval: number;
}
export interface ParsedTimeRange {
minTime: number;
maxTime: number;
}
export interface IGlobalTimeStoreActions {
/**
* Set the selected time and optionally the refresh interval.
* isRefreshEnabled is automatically computed:
* - Custom time ranges: always false
* - Duration times with refreshInterval > 0: true
* - Duration times with refreshInterval = 0: false
*/
setSelectedTime: (
selectedTime: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
/**
* Get the current min/max time values parsed from selectedTime.
* For durations, computes fresh values based on Date.now().
* For custom ranges, extracts the stored values.
*/
getMinMaxTime: () => ParsedTimeRange;
}

View File

@@ -0,0 +1,87 @@
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { REACT_QUERY_KEY } from '../../constants/reactQueryKeys';
import {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeSelectedTime,
ParsedTimeRange,
} from './types';
/**
* Custom time range separator used in the selectedTime string
*/
export const CUSTOM_TIME_SEPARATOR: CustomTimeRangeSeparator = '||_||';
/**
* Check if selectedTime represents a custom time range
*/
export function isCustomTimeRange(selectedTime: string): boolean {
return selectedTime.includes(CUSTOM_TIME_SEPARATOR);
}
/**
* Create a custom time range string from min/max times (in nanoseconds)
*/
export function createCustomTimeRange(
minTime: number,
maxTime: number,
): CustomTimeRange {
return `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
}
/**
* Parse the custom time range string to get min/max times (in nanoseconds)
*/
export function parseCustomTimeRange(
selectedTime: string,
): ParsedTimeRange | null {
if (!isCustomTimeRange(selectedTime)) {
return null;
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
}
return { minTime, maxTime };
}
export const NANO_SECOND_MULTIPLIER = 1000000;
const fallbackDurationInNanoSeconds = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s
/**
* Parse the selectedTime string to get min/max time values.
* For relative times, computes fresh values based on Date.now().
* For custom times, extracts the stored min/max values.
*/
export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
const parsed = parseCustomTimeRange(selectedTime);
if (parsed) {
return parsed;
}
// Fallback to current time if parsing fails
const now = Date.now() * NANO_SECOND_MULTIPLIER;
return { minTime: now - fallbackDurationInNanoSeconds, maxTime: now };
}
// It's a relative time like '15m', '1h', etc.
// Use getMinMaxForSelectedTime which computes from Date.now()
return getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
}
/**
* Use to build your react-query key for auto-refresh queries
*/
export function getAutoRefreshQueryKey(
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
): unknown[] {
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}

256
grammar/HavingExpression.g4 Normal file
View File

@@ -0,0 +1,256 @@
grammar HavingExpression;
/*
* Parser Rules
*/
query
: expression EOF
;
// Expression with standard boolean precedence:
// - parentheses > NOT > AND > OR
expression
: orExpression
;
// OR expressions
orExpression
: andExpression ( OR andExpression )*
;
// AND expressions + optional chaining with implicit AND if no OR is present
andExpression
: primary ( AND primary | primary )*
;
// Primary: an optionally negated expression.
// NOT can be applied to a parenthesized expression or a bare comparison / IN-test.
// E.g.: NOT (count() > 100 AND sum(bytes) < 500)
// NOT count() > 100
// count() IN (1, 2, 3) -- NOT here is part of comparison, see below
// count() NOT IN (1, 2, 3)
primary
: NOT? LPAREN orExpression RPAREN
| NOT? comparison
;
/*
* Comparison between two arithmetic operands, or an IN / NOT IN membership test.
* E.g.: count() > 100, total_duration >= 500, __result_0 != 0
* count() IN (1, 2, 3), sum(bytes) NOT IN (0, -1)
* count() IN [1, 2, 3], sum(bytes) NOT IN [0, -1]
*/
comparison
: operand compOp operand
| operand NOT? IN LPAREN inList RPAREN
| operand NOT? IN LBRACK inList RBRACK
;
compOp
: EQUALS
| NOT_EQUALS
| NEQ
| LT
| LE
| GT
| GE
;
/*
* IN-list: a comma-separated list of numeric literals, each optionally signed.
* E.g.: (1, 2, 3), [100, 200, 500], (-1, 0, 1)
*/
inList
: signedNumber ( COMMA signedNumber )*
;
/*
* A signed number allows an optional leading +/- before a numeric literal.
* Used in IN-lists where a bare minus is unambiguous (no binary operand to the left).
*/
signedNumber
: (PLUS | MINUS)? NUMBER
;
/*
* Operands support additive arithmetic (+/-).
* E.g.: sum(a) + sum(b) > 1000, count() - 10 > 0
*/
operand
: operand (PLUS | MINUS) term
| term
;
/*
* Terms support multiplicative arithmetic (*, /, %)
* E.g.: count() * 2 > 100, sum(bytes) / 1024 > 10
*/
term
: term (STAR | SLASH | PERCENT) factor
| factor
;
/*
* Factors: atoms, parenthesized operands, or unary-signed sub-factors.
* E.g.: (sum(a) + sum(b)) * 2 > 100, -count() > 0, -(avg(x) + 1) > 0
* -10 (unary minus applied to the literal 10), count() - 10 > 0
*
* Note: the NUMBER rule does NOT include a leading sign, so `-10` is always
* tokenised as MINUS followed by NUMBER(10). Unary minus in `factor` handles
* negative literals just as it handles negative function calls or identifiers,
* and the binary MINUS in `operand` handles `count()-10` naturally.
*/
factor
: (PLUS | MINUS) factor
| LPAREN operand RPAREN
| atom
;
/*
* Atoms are the basic building blocks of arithmetic operands:
* - aggregate function calls: count(), sum(bytes), avg(duration)
* - identifier references: aliases, result refs (__result, __result_0, __result0)
* - numeric literals: 100, 0.5, 1e6
* - string literals: 'xyz' — recognized so we can give a friendly error
*
* String literals in HAVING are always invalid (aggregator results are numeric),
* but we accept them here so the visitor can produce a clear error message instead
* of a raw syntax error.
*/
atom
: functionCall
| identifier
| NUMBER
| STRING
;
/*
* Aggregate function calls, e.g.:
* count(), sum(bytes), avg(duration_nano)
* countIf(level='error'), sumIf(bytes, status > 400)
* p99(duration), avg(sum(cpu_usage))
*
* Function arguments are parsed as a permissive token sequence (funcArgToken+)
* so that complex aggregation expressions — including nested function calls and
* filter predicates with string literals — can be referenced verbatim in the
* HAVING expression. The visitor looks up the full call text (whitespace-free,
* via ctx.GetText()) in the column map, which stores normalized (space-stripped)
* aggregation expression keys.
*/
functionCall
: IDENTIFIER LPAREN functionArgList? RPAREN
;
functionArgList
: funcArg ( COMMA funcArg )*
;
/*
* A single function argument is one or more consecutive arg-tokens.
* Commas at the top level separate arguments; closing parens terminate the list.
*/
funcArg
: funcArgToken+
;
/*
* Permissive token set for function argument content. Covers:
* - simple identifiers: bytes, duration
* - string literals: 'error', "info"
* - numeric literals: 200, 3.14
* - comparison operators: level='error', status > 400
* - arithmetic operators: x + y
* - boolean connectives: level='error' AND status=200
* - balanced parens: nested calls like sum(duration)
*/
funcArgToken
: IDENTIFIER
| STRING
| NUMBER
| BOOL
| EQUALS | NOT_EQUALS | NEQ | LT | LE | GT | GE
| PLUS | MINUS | STAR | SLASH | PERCENT
| NOT | AND | OR
| LPAREN funcArgToken* RPAREN
;
// Identifier references: aliases, field names, result references
// Examples: total_logs, error_count, __result, __result_0, __result0, p99
identifier
: IDENTIFIER
;
/*
* Lexer Rules
*/
// Punctuation
LPAREN : '(' ;
RPAREN : ')' ;
LBRACK : '[' ;
RBRACK : ']' ;
COMMA : ',' ;
// Comparison operators
EQUALS : '=' | '==' ;
NOT_EQUALS : '!=' ;
NEQ : '<>' ; // alternate not-equals operator
LT : '<' ;
LE : '<=' ;
GT : '>' ;
GE : '>=' ;
// Arithmetic operators
PLUS : '+' ;
MINUS : '-' ;
STAR : '*' ;
SLASH : '/' ;
PERCENT : '%' ;
// Boolean logic (case-insensitive)
NOT : [Nn][Oo][Tt] ;
AND : [Aa][Nn][Dd] ;
OR : [Oo][Rr] ;
IN : [Ii][Nn] ;
// Boolean constants (case-insensitive)
BOOL
: [Tt][Rr][Uu][Ee]
| [Ff][Aa][Ll][Ss][Ee]
;
fragment SIGN : [+-] ;
// Numbers: digits, optional decimal, optional scientific notation.
// No leading sign — a leading +/- is always a separate PLUS/MINUS token, which
// lets the parser treat it as either a binary operator (count()-10) or unary
// sign (-count(), -10). Signed exponents like 1e-3 remain valid.
// E.g.: 100, 0.5, 1.5e3, .75
NUMBER
: DIGIT+ ('.' DIGIT*)? ([eE] SIGN? DIGIT+)?
| '.' DIGIT+ ([eE] SIGN? DIGIT+)?
;
// Identifiers: start with a letter or underscore, followed by alphanumeric/underscores.
// Optionally dotted for nested field paths.
// Covers: count, sum, p99, total_logs, error_count, __result, __result_0, __result0,
// service.name, span.duration
IDENTIFIER
: [a-zA-Z_] [a-zA-Z0-9_]* ( '.' [a-zA-Z_] [a-zA-Z0-9_]* )*
;
// Quoted string literals (single or double-quoted).
// These are valid tokens inside function arguments (e.g. countIf(level='error'))
// but are always rejected in comparison-operand position by the visitor.
STRING
: '\'' (~'\'')* '\''
| '"' (~'"')* '"'
;
// Skip whitespace
WS
: [ \t\r\n]+ -> skip
;
fragment DIGIT : [0-9] ;

View File

@@ -5,7 +5,7 @@ import (
"slices"
"strings"
parser "github.com/SigNoz/signoz/pkg/parser/grammar"
parser "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/antlr4-go/antlr/v4"
"golang.org/x/exp/maps"

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -123,8 +124,8 @@ func (m *module) RecordRuleStateHistory(ctx context.Context, ruleID string, hand
for _, item := range lastSavedState {
currentState, ok := currentItemsByFingerprint[item.Fingerprint]
if !ok {
if item.State == rulestatehistorytypes.StateFiring || item.State == rulestatehistorytypes.StateNoData {
item.State = rulestatehistorytypes.StateInactive
if item.State == ruletypes.StateFiring || item.State == ruletypes.StateNoData {
item.State = ruletypes.StateInactive
item.StateChanged = true
item.UnixMilli = time.Now().UnixMilli()
revisedItemsToAdd[item.Fingerprint] = item
@@ -145,10 +146,10 @@ func (m *module) RecordRuleStateHistory(ctx context.Context, ruleID string, hand
}
}
newState := rulestatehistorytypes.StateInactive
newState := ruletypes.StateInactive
for _, item := range revisedItemsToAdd {
if item.State == rulestatehistorytypes.StateFiring || item.State == rulestatehistorytypes.StateNoData {
newState = rulestatehistorytypes.StateFiring
if item.State == ruletypes.StateFiring || item.State == ruletypes.StateNoData {
newState = ruletypes.StateFiring
break
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
@@ -300,7 +301,7 @@ func (s *store) ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context,
sb.From(historyTable())
sb.Where(sb.E("rule_id", ruleID))
sb.Where(sb.E("state_changed", true))
sb.Where(sb.E("state", rulestatehistorytypes.StateFiring.StringValue()))
sb.Where(sb.E("state", ruletypes.StateFiring.StringValue()))
sb.Where(sb.GE("unix_milli", query.Start))
sb.Where(sb.LT("unix_milli", query.End))
@@ -341,7 +342,7 @@ WHERE rule_id = %s
AND unix_milli < %s
GROUP BY unix_milli`,
innerSB.Var(query.Start),
innerSB.Var(rulestatehistorytypes.StateInactive.StringValue()),
innerSB.Var(ruletypes.StateInactive.StringValue()),
historyTable(),
innerSB.Var(ruleID),
innerSB.Var(query.Start),
@@ -411,7 +412,7 @@ func (s *store) GetTotalTriggers(ctx context.Context, ruleID string, query *rule
sb.From(historyTable())
sb.Where(sb.E("rule_id", ruleID))
sb.Where(sb.E("state_changed", true))
sb.Where(sb.E("state", rulestatehistorytypes.StateFiring.StringValue()))
sb.Where(sb.E("state", ruletypes.StateFiring.StringValue()))
sb.Where(sb.GE("unix_milli", query.Start))
sb.Where(sb.LT("unix_milli", query.End))
selectQuery, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
@@ -432,7 +433,7 @@ func (s *store) GetTriggersByInterval(ctx context.Context, ruleID string, query
sb.From(historyTable())
sb.Where(sb.E("rule_id", ruleID))
sb.Where(sb.E("state_changed", true))
sb.Where(sb.E("state", rulestatehistorytypes.StateFiring.StringValue()))
sb.Where(sb.E("state", ruletypes.StateFiring.StringValue()))
sb.Where(sb.GE("unix_milli", query.Start))
sb.Where(sb.LT("unix_milli", query.End))
sb.GroupBy("ts")
@@ -528,7 +529,7 @@ func (s *store) buildMatchedEventsCTE(ruleID string, query *rulestatehistorytype
firingSB := sqlbuilder.NewSelectBuilder()
firingSB.Select("rule_id", "unix_milli AS firing_time")
firingSB.From(historyTable())
firingSB.Where(firingSB.E("overall_state", rulestatehistorytypes.StateFiring.StringValue()))
firingSB.Where(firingSB.E("overall_state", ruletypes.StateFiring.StringValue()))
firingSB.Where(firingSB.E("overall_state_changed", true))
firingSB.Where(firingSB.E("rule_id", ruleID))
firingSB.Where(firingSB.GE("unix_milli", query.Start))
@@ -537,7 +538,7 @@ func (s *store) buildMatchedEventsCTE(ruleID string, query *rulestatehistorytype
resolutionSB := sqlbuilder.NewSelectBuilder()
resolutionSB.Select("rule_id", "unix_milli AS resolution_time")
resolutionSB.From(historyTable())
resolutionSB.Where(resolutionSB.E("overall_state", rulestatehistorytypes.StateInactive.StringValue()))
resolutionSB.Where(resolutionSB.E("overall_state", ruletypes.StateInactive.StringValue()))
resolutionSB.Where(resolutionSB.E("overall_state_changed", true))
resolutionSB.Where(resolutionSB.E("rule_id", ruleID))
resolutionSB.Where(resolutionSB.GE("unix_milli", query.Start))

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,42 @@
LPAREN=1
RPAREN=2
LBRACK=3
RBRACK=4
COMMA=5
EQUALS=6
NOT_EQUALS=7
NEQ=8
LT=9
LE=10
GT=11
GE=12
PLUS=13
MINUS=14
STAR=15
SLASH=16
PERCENT=17
NOT=18
AND=19
OR=20
IN=21
BOOL=22
NUMBER=23
IDENTIFIER=24
STRING=25
WS=26
'('=1
')'=2
'['=3
']'=4
','=5
'!='=7
'<>'=8
'<'=9
'<='=10
'>'=11
'>='=12
'+'=13
'-'=14
'*'=15
'/'=16
'%'=17

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,42 @@
LPAREN=1
RPAREN=2
LBRACK=3
RBRACK=4
COMMA=5
EQUALS=6
NOT_EQUALS=7
NEQ=8
LT=9
LE=10
GT=11
GE=12
PLUS=13
MINUS=14
STAR=15
SLASH=16
PERCENT=17
NOT=18
AND=19
OR=20
IN=21
BOOL=22
NUMBER=23
IDENTIFIER=24
STRING=25
WS=26
'('=1
')'=2
'['=3
']'=4
','=5
'!='=7
'<>'=8
'<'=9
'<='=10
'>'=11
'>='=12
'+'=13
'-'=14
'*'=15
'/'=16
'%'=17

View File

@@ -0,0 +1,130 @@
// Code generated from grammar/HavingExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // HavingExpression
import "github.com/antlr4-go/antlr/v4"
// BaseHavingExpressionListener is a complete listener for a parse tree produced by HavingExpressionParser.
type BaseHavingExpressionListener struct{}
var _ HavingExpressionListener = &BaseHavingExpressionListener{}
// VisitTerminal is called when a terminal node is visited.
func (s *BaseHavingExpressionListener) VisitTerminal(node antlr.TerminalNode) {}
// VisitErrorNode is called when an error node is visited.
func (s *BaseHavingExpressionListener) VisitErrorNode(node antlr.ErrorNode) {}
// EnterEveryRule is called when any rule is entered.
func (s *BaseHavingExpressionListener) EnterEveryRule(ctx antlr.ParserRuleContext) {}
// ExitEveryRule is called when any rule is exited.
func (s *BaseHavingExpressionListener) ExitEveryRule(ctx antlr.ParserRuleContext) {}
// EnterQuery is called when production query is entered.
func (s *BaseHavingExpressionListener) EnterQuery(ctx *QueryContext) {}
// ExitQuery is called when production query is exited.
func (s *BaseHavingExpressionListener) ExitQuery(ctx *QueryContext) {}
// EnterExpression is called when production expression is entered.
func (s *BaseHavingExpressionListener) EnterExpression(ctx *ExpressionContext) {}
// ExitExpression is called when production expression is exited.
func (s *BaseHavingExpressionListener) ExitExpression(ctx *ExpressionContext) {}
// EnterOrExpression is called when production orExpression is entered.
func (s *BaseHavingExpressionListener) EnterOrExpression(ctx *OrExpressionContext) {}
// ExitOrExpression is called when production orExpression is exited.
func (s *BaseHavingExpressionListener) ExitOrExpression(ctx *OrExpressionContext) {}
// EnterAndExpression is called when production andExpression is entered.
func (s *BaseHavingExpressionListener) EnterAndExpression(ctx *AndExpressionContext) {}
// ExitAndExpression is called when production andExpression is exited.
func (s *BaseHavingExpressionListener) ExitAndExpression(ctx *AndExpressionContext) {}
// EnterPrimary is called when production primary is entered.
func (s *BaseHavingExpressionListener) EnterPrimary(ctx *PrimaryContext) {}
// ExitPrimary is called when production primary is exited.
func (s *BaseHavingExpressionListener) ExitPrimary(ctx *PrimaryContext) {}
// EnterComparison is called when production comparison is entered.
func (s *BaseHavingExpressionListener) EnterComparison(ctx *ComparisonContext) {}
// ExitComparison is called when production comparison is exited.
func (s *BaseHavingExpressionListener) ExitComparison(ctx *ComparisonContext) {}
// EnterCompOp is called when production compOp is entered.
func (s *BaseHavingExpressionListener) EnterCompOp(ctx *CompOpContext) {}
// ExitCompOp is called when production compOp is exited.
func (s *BaseHavingExpressionListener) ExitCompOp(ctx *CompOpContext) {}
// EnterInList is called when production inList is entered.
func (s *BaseHavingExpressionListener) EnterInList(ctx *InListContext) {}
// ExitInList is called when production inList is exited.
func (s *BaseHavingExpressionListener) ExitInList(ctx *InListContext) {}
// EnterSignedNumber is called when production signedNumber is entered.
func (s *BaseHavingExpressionListener) EnterSignedNumber(ctx *SignedNumberContext) {}
// ExitSignedNumber is called when production signedNumber is exited.
func (s *BaseHavingExpressionListener) ExitSignedNumber(ctx *SignedNumberContext) {}
// EnterOperand is called when production operand is entered.
func (s *BaseHavingExpressionListener) EnterOperand(ctx *OperandContext) {}
// ExitOperand is called when production operand is exited.
func (s *BaseHavingExpressionListener) ExitOperand(ctx *OperandContext) {}
// EnterTerm is called when production term is entered.
func (s *BaseHavingExpressionListener) EnterTerm(ctx *TermContext) {}
// ExitTerm is called when production term is exited.
func (s *BaseHavingExpressionListener) ExitTerm(ctx *TermContext) {}
// EnterFactor is called when production factor is entered.
func (s *BaseHavingExpressionListener) EnterFactor(ctx *FactorContext) {}
// ExitFactor is called when production factor is exited.
func (s *BaseHavingExpressionListener) ExitFactor(ctx *FactorContext) {}
// EnterAtom is called when production atom is entered.
func (s *BaseHavingExpressionListener) EnterAtom(ctx *AtomContext) {}
// ExitAtom is called when production atom is exited.
func (s *BaseHavingExpressionListener) ExitAtom(ctx *AtomContext) {}
// EnterFunctionCall is called when production functionCall is entered.
func (s *BaseHavingExpressionListener) EnterFunctionCall(ctx *FunctionCallContext) {}
// ExitFunctionCall is called when production functionCall is exited.
func (s *BaseHavingExpressionListener) ExitFunctionCall(ctx *FunctionCallContext) {}
// EnterFunctionArgList is called when production functionArgList is entered.
func (s *BaseHavingExpressionListener) EnterFunctionArgList(ctx *FunctionArgListContext) {}
// ExitFunctionArgList is called when production functionArgList is exited.
func (s *BaseHavingExpressionListener) ExitFunctionArgList(ctx *FunctionArgListContext) {}
// EnterFuncArg is called when production funcArg is entered.
func (s *BaseHavingExpressionListener) EnterFuncArg(ctx *FuncArgContext) {}
// ExitFuncArg is called when production funcArg is exited.
func (s *BaseHavingExpressionListener) ExitFuncArg(ctx *FuncArgContext) {}
// EnterFuncArgToken is called when production funcArgToken is entered.
func (s *BaseHavingExpressionListener) EnterFuncArgToken(ctx *FuncArgTokenContext) {}
// ExitFuncArgToken is called when production funcArgToken is exited.
func (s *BaseHavingExpressionListener) ExitFuncArgToken(ctx *FuncArgTokenContext) {}
// EnterIdentifier is called when production identifier is entered.
func (s *BaseHavingExpressionListener) EnterIdentifier(ctx *IdentifierContext) {}
// ExitIdentifier is called when production identifier is exited.
func (s *BaseHavingExpressionListener) ExitIdentifier(ctx *IdentifierContext) {}

View File

@@ -0,0 +1,81 @@
// Code generated from grammar/HavingExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // HavingExpression
import "github.com/antlr4-go/antlr/v4"
type BaseHavingExpressionVisitor struct {
*antlr.BaseParseTreeVisitor
}
func (v *BaseHavingExpressionVisitor) VisitQuery(ctx *QueryContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitExpression(ctx *ExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitOrExpression(ctx *OrExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitAndExpression(ctx *AndExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitPrimary(ctx *PrimaryContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitComparison(ctx *ComparisonContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitCompOp(ctx *CompOpContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitInList(ctx *InListContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitSignedNumber(ctx *SignedNumberContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitOperand(ctx *OperandContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitTerm(ctx *TermContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitFactor(ctx *FactorContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitAtom(ctx *AtomContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitFunctionCall(ctx *FunctionCallContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitFunctionArgList(ctx *FunctionArgListContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitFuncArg(ctx *FuncArgContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitFuncArgToken(ctx *FuncArgTokenContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseHavingExpressionVisitor) VisitIdentifier(ctx *IdentifierContext) interface{} {
return v.VisitChildren(ctx)
}

View File

@@ -0,0 +1,232 @@
// Code generated from grammar/HavingExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser
import (
"fmt"
"github.com/antlr4-go/antlr/v4"
"sync"
"unicode"
)
// Suppress unused import error
var _ = fmt.Printf
var _ = sync.Once{}
var _ = unicode.IsLetter
type HavingExpressionLexer struct {
*antlr.BaseLexer
channelNames []string
modeNames []string
// TODO: EOF string
}
var HavingExpressionLexerLexerStaticData struct {
once sync.Once
serializedATN []int32
ChannelNames []string
ModeNames []string
LiteralNames []string
SymbolicNames []string
RuleNames []string
PredictionContextCache *antlr.PredictionContextCache
atn *antlr.ATN
decisionToDFA []*antlr.DFA
}
func havingexpressionlexerLexerInit() {
staticData := &HavingExpressionLexerLexerStaticData
staticData.ChannelNames = []string{
"DEFAULT_TOKEN_CHANNEL", "HIDDEN",
}
staticData.ModeNames = []string{
"DEFAULT_MODE",
}
staticData.LiteralNames = []string{
"", "'('", "')'", "'['", "']'", "','", "", "'!='", "'<>'", "'<'", "'<='",
"'>'", "'>='", "'+'", "'-'", "'*'", "'/'", "'%'",
}
staticData.SymbolicNames = []string{
"", "LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "PLUS", "MINUS", "STAR", "SLASH", "PERCENT",
"NOT", "AND", "OR", "IN", "BOOL", "NUMBER", "IDENTIFIER", "STRING",
"WS",
}
staticData.RuleNames = []string{
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "PLUS", "MINUS", "STAR", "SLASH", "PERCENT",
"NOT", "AND", "OR", "IN", "BOOL", "SIGN", "NUMBER", "IDENTIFIER", "STRING",
"WS", "DIGIT",
}
staticData.PredictionContextCache = antlr.NewPredictionContextCache()
staticData.serializedATN = []int32{
4, 0, 26, 216, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2,
10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15,
7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7,
20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25,
2, 26, 7, 26, 2, 27, 7, 27, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1,
3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 3, 5, 71, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7,
1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1,
11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 15, 1, 15, 1, 16, 1, 16,
1, 17, 1, 17, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1,
19, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21,
1, 21, 1, 21, 3, 21, 122, 8, 21, 1, 22, 1, 22, 1, 23, 4, 23, 127, 8, 23,
11, 23, 12, 23, 128, 1, 23, 1, 23, 5, 23, 133, 8, 23, 10, 23, 12, 23, 136,
9, 23, 3, 23, 138, 8, 23, 1, 23, 1, 23, 3, 23, 142, 8, 23, 1, 23, 4, 23,
145, 8, 23, 11, 23, 12, 23, 146, 3, 23, 149, 8, 23, 1, 23, 1, 23, 4, 23,
153, 8, 23, 11, 23, 12, 23, 154, 1, 23, 1, 23, 3, 23, 159, 8, 23, 1, 23,
4, 23, 162, 8, 23, 11, 23, 12, 23, 163, 3, 23, 166, 8, 23, 3, 23, 168,
8, 23, 1, 24, 1, 24, 5, 24, 172, 8, 24, 10, 24, 12, 24, 175, 9, 24, 1,
24, 1, 24, 1, 24, 5, 24, 180, 8, 24, 10, 24, 12, 24, 183, 9, 24, 5, 24,
185, 8, 24, 10, 24, 12, 24, 188, 9, 24, 1, 25, 1, 25, 5, 25, 192, 8, 25,
10, 25, 12, 25, 195, 9, 25, 1, 25, 1, 25, 1, 25, 5, 25, 200, 8, 25, 10,
25, 12, 25, 203, 9, 25, 1, 25, 3, 25, 206, 8, 25, 1, 26, 4, 26, 209, 8,
26, 11, 26, 12, 26, 210, 1, 26, 1, 26, 1, 27, 1, 27, 0, 0, 28, 1, 1, 3,
2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12,
25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19, 39, 20, 41, 21,
43, 22, 45, 0, 47, 23, 49, 24, 51, 25, 53, 26, 55, 0, 1, 0, 19, 2, 0, 78,
78, 110, 110, 2, 0, 79, 79, 111, 111, 2, 0, 84, 84, 116, 116, 2, 0, 65,
65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 82, 82, 114, 114, 2, 0, 73, 73,
105, 105, 2, 0, 85, 85, 117, 117, 2, 0, 69, 69, 101, 101, 2, 0, 70, 70,
102, 102, 2, 0, 76, 76, 108, 108, 2, 0, 83, 83, 115, 115, 2, 0, 43, 43,
45, 45, 3, 0, 65, 90, 95, 95, 97, 122, 4, 0, 48, 57, 65, 90, 95, 95, 97,
122, 1, 0, 39, 39, 1, 0, 34, 34, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48,
57, 233, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1,
0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15,
1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0,
23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0,
0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0,
0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 47, 1, 0,
0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 1, 57, 1,
0, 0, 0, 3, 59, 1, 0, 0, 0, 5, 61, 1, 0, 0, 0, 7, 63, 1, 0, 0, 0, 9, 65,
1, 0, 0, 0, 11, 70, 1, 0, 0, 0, 13, 72, 1, 0, 0, 0, 15, 75, 1, 0, 0, 0,
17, 78, 1, 0, 0, 0, 19, 80, 1, 0, 0, 0, 21, 83, 1, 0, 0, 0, 23, 85, 1,
0, 0, 0, 25, 88, 1, 0, 0, 0, 27, 90, 1, 0, 0, 0, 29, 92, 1, 0, 0, 0, 31,
94, 1, 0, 0, 0, 33, 96, 1, 0, 0, 0, 35, 98, 1, 0, 0, 0, 37, 102, 1, 0,
0, 0, 39, 106, 1, 0, 0, 0, 41, 109, 1, 0, 0, 0, 43, 121, 1, 0, 0, 0, 45,
123, 1, 0, 0, 0, 47, 167, 1, 0, 0, 0, 49, 169, 1, 0, 0, 0, 51, 205, 1,
0, 0, 0, 53, 208, 1, 0, 0, 0, 55, 214, 1, 0, 0, 0, 57, 58, 5, 40, 0, 0,
58, 2, 1, 0, 0, 0, 59, 60, 5, 41, 0, 0, 60, 4, 1, 0, 0, 0, 61, 62, 5, 91,
0, 0, 62, 6, 1, 0, 0, 0, 63, 64, 5, 93, 0, 0, 64, 8, 1, 0, 0, 0, 65, 66,
5, 44, 0, 0, 66, 10, 1, 0, 0, 0, 67, 71, 5, 61, 0, 0, 68, 69, 5, 61, 0,
0, 69, 71, 5, 61, 0, 0, 70, 67, 1, 0, 0, 0, 70, 68, 1, 0, 0, 0, 71, 12,
1, 0, 0, 0, 72, 73, 5, 33, 0, 0, 73, 74, 5, 61, 0, 0, 74, 14, 1, 0, 0,
0, 75, 76, 5, 60, 0, 0, 76, 77, 5, 62, 0, 0, 77, 16, 1, 0, 0, 0, 78, 79,
5, 60, 0, 0, 79, 18, 1, 0, 0, 0, 80, 81, 5, 60, 0, 0, 81, 82, 5, 61, 0,
0, 82, 20, 1, 0, 0, 0, 83, 84, 5, 62, 0, 0, 84, 22, 1, 0, 0, 0, 85, 86,
5, 62, 0, 0, 86, 87, 5, 61, 0, 0, 87, 24, 1, 0, 0, 0, 88, 89, 5, 43, 0,
0, 89, 26, 1, 0, 0, 0, 90, 91, 5, 45, 0, 0, 91, 28, 1, 0, 0, 0, 92, 93,
5, 42, 0, 0, 93, 30, 1, 0, 0, 0, 94, 95, 5, 47, 0, 0, 95, 32, 1, 0, 0,
0, 96, 97, 5, 37, 0, 0, 97, 34, 1, 0, 0, 0, 98, 99, 7, 0, 0, 0, 99, 100,
7, 1, 0, 0, 100, 101, 7, 2, 0, 0, 101, 36, 1, 0, 0, 0, 102, 103, 7, 3,
0, 0, 103, 104, 7, 0, 0, 0, 104, 105, 7, 4, 0, 0, 105, 38, 1, 0, 0, 0,
106, 107, 7, 1, 0, 0, 107, 108, 7, 5, 0, 0, 108, 40, 1, 0, 0, 0, 109, 110,
7, 6, 0, 0, 110, 111, 7, 0, 0, 0, 111, 42, 1, 0, 0, 0, 112, 113, 7, 2,
0, 0, 113, 114, 7, 5, 0, 0, 114, 115, 7, 7, 0, 0, 115, 122, 7, 8, 0, 0,
116, 117, 7, 9, 0, 0, 117, 118, 7, 3, 0, 0, 118, 119, 7, 10, 0, 0, 119,
120, 7, 11, 0, 0, 120, 122, 7, 8, 0, 0, 121, 112, 1, 0, 0, 0, 121, 116,
1, 0, 0, 0, 122, 44, 1, 0, 0, 0, 123, 124, 7, 12, 0, 0, 124, 46, 1, 0,
0, 0, 125, 127, 3, 55, 27, 0, 126, 125, 1, 0, 0, 0, 127, 128, 1, 0, 0,
0, 128, 126, 1, 0, 0, 0, 128, 129, 1, 0, 0, 0, 129, 137, 1, 0, 0, 0, 130,
134, 5, 46, 0, 0, 131, 133, 3, 55, 27, 0, 132, 131, 1, 0, 0, 0, 133, 136,
1, 0, 0, 0, 134, 132, 1, 0, 0, 0, 134, 135, 1, 0, 0, 0, 135, 138, 1, 0,
0, 0, 136, 134, 1, 0, 0, 0, 137, 130, 1, 0, 0, 0, 137, 138, 1, 0, 0, 0,
138, 148, 1, 0, 0, 0, 139, 141, 7, 8, 0, 0, 140, 142, 3, 45, 22, 0, 141,
140, 1, 0, 0, 0, 141, 142, 1, 0, 0, 0, 142, 144, 1, 0, 0, 0, 143, 145,
3, 55, 27, 0, 144, 143, 1, 0, 0, 0, 145, 146, 1, 0, 0, 0, 146, 144, 1,
0, 0, 0, 146, 147, 1, 0, 0, 0, 147, 149, 1, 0, 0, 0, 148, 139, 1, 0, 0,
0, 148, 149, 1, 0, 0, 0, 149, 168, 1, 0, 0, 0, 150, 152, 5, 46, 0, 0, 151,
153, 3, 55, 27, 0, 152, 151, 1, 0, 0, 0, 153, 154, 1, 0, 0, 0, 154, 152,
1, 0, 0, 0, 154, 155, 1, 0, 0, 0, 155, 165, 1, 0, 0, 0, 156, 158, 7, 8,
0, 0, 157, 159, 3, 45, 22, 0, 158, 157, 1, 0, 0, 0, 158, 159, 1, 0, 0,
0, 159, 161, 1, 0, 0, 0, 160, 162, 3, 55, 27, 0, 161, 160, 1, 0, 0, 0,
162, 163, 1, 0, 0, 0, 163, 161, 1, 0, 0, 0, 163, 164, 1, 0, 0, 0, 164,
166, 1, 0, 0, 0, 165, 156, 1, 0, 0, 0, 165, 166, 1, 0, 0, 0, 166, 168,
1, 0, 0, 0, 167, 126, 1, 0, 0, 0, 167, 150, 1, 0, 0, 0, 168, 48, 1, 0,
0, 0, 169, 173, 7, 13, 0, 0, 170, 172, 7, 14, 0, 0, 171, 170, 1, 0, 0,
0, 172, 175, 1, 0, 0, 0, 173, 171, 1, 0, 0, 0, 173, 174, 1, 0, 0, 0, 174,
186, 1, 0, 0, 0, 175, 173, 1, 0, 0, 0, 176, 177, 5, 46, 0, 0, 177, 181,
7, 13, 0, 0, 178, 180, 7, 14, 0, 0, 179, 178, 1, 0, 0, 0, 180, 183, 1,
0, 0, 0, 181, 179, 1, 0, 0, 0, 181, 182, 1, 0, 0, 0, 182, 185, 1, 0, 0,
0, 183, 181, 1, 0, 0, 0, 184, 176, 1, 0, 0, 0, 185, 188, 1, 0, 0, 0, 186,
184, 1, 0, 0, 0, 186, 187, 1, 0, 0, 0, 187, 50, 1, 0, 0, 0, 188, 186, 1,
0, 0, 0, 189, 193, 5, 39, 0, 0, 190, 192, 8, 15, 0, 0, 191, 190, 1, 0,
0, 0, 192, 195, 1, 0, 0, 0, 193, 191, 1, 0, 0, 0, 193, 194, 1, 0, 0, 0,
194, 196, 1, 0, 0, 0, 195, 193, 1, 0, 0, 0, 196, 206, 5, 39, 0, 0, 197,
201, 5, 34, 0, 0, 198, 200, 8, 16, 0, 0, 199, 198, 1, 0, 0, 0, 200, 203,
1, 0, 0, 0, 201, 199, 1, 0, 0, 0, 201, 202, 1, 0, 0, 0, 202, 204, 1, 0,
0, 0, 203, 201, 1, 0, 0, 0, 204, 206, 5, 34, 0, 0, 205, 189, 1, 0, 0, 0,
205, 197, 1, 0, 0, 0, 206, 52, 1, 0, 0, 0, 207, 209, 7, 17, 0, 0, 208,
207, 1, 0, 0, 0, 209, 210, 1, 0, 0, 0, 210, 208, 1, 0, 0, 0, 210, 211,
1, 0, 0, 0, 211, 212, 1, 0, 0, 0, 212, 213, 6, 26, 0, 0, 213, 54, 1, 0,
0, 0, 214, 215, 7, 18, 0, 0, 215, 56, 1, 0, 0, 0, 21, 0, 70, 121, 128,
134, 137, 141, 146, 148, 154, 158, 163, 165, 167, 173, 181, 186, 193, 201,
205, 210, 1, 6, 0, 0,
}
deserializer := antlr.NewATNDeserializer(nil)
staticData.atn = deserializer.Deserialize(staticData.serializedATN)
atn := staticData.atn
staticData.decisionToDFA = make([]*antlr.DFA, len(atn.DecisionToState))
decisionToDFA := staticData.decisionToDFA
for index, state := range atn.DecisionToState {
decisionToDFA[index] = antlr.NewDFA(state, index)
}
}
// HavingExpressionLexerInit initializes any static state used to implement HavingExpressionLexer. By default the
// static state used to implement the lexer is lazily initialized during the first call to
// NewHavingExpressionLexer(). You can call this function if you wish to initialize the static state ahead
// of time.
func HavingExpressionLexerInit() {
staticData := &HavingExpressionLexerLexerStaticData
staticData.once.Do(havingexpressionlexerLexerInit)
}
// NewHavingExpressionLexer produces a new lexer instance for the optional input antlr.CharStream.
func NewHavingExpressionLexer(input antlr.CharStream) *HavingExpressionLexer {
HavingExpressionLexerInit()
l := new(HavingExpressionLexer)
l.BaseLexer = antlr.NewBaseLexer(input)
staticData := &HavingExpressionLexerLexerStaticData
l.Interpreter = antlr.NewLexerATNSimulator(l, staticData.atn, staticData.decisionToDFA, staticData.PredictionContextCache)
l.channelNames = staticData.ChannelNames
l.modeNames = staticData.ModeNames
l.RuleNames = staticData.RuleNames
l.LiteralNames = staticData.LiteralNames
l.SymbolicNames = staticData.SymbolicNames
l.GrammarFileName = "HavingExpression.g4"
// TODO: l.EOF = antlr.TokenEOF
return l
}
// HavingExpressionLexer tokens.
const (
HavingExpressionLexerLPAREN = 1
HavingExpressionLexerRPAREN = 2
HavingExpressionLexerLBRACK = 3
HavingExpressionLexerRBRACK = 4
HavingExpressionLexerCOMMA = 5
HavingExpressionLexerEQUALS = 6
HavingExpressionLexerNOT_EQUALS = 7
HavingExpressionLexerNEQ = 8
HavingExpressionLexerLT = 9
HavingExpressionLexerLE = 10
HavingExpressionLexerGT = 11
HavingExpressionLexerGE = 12
HavingExpressionLexerPLUS = 13
HavingExpressionLexerMINUS = 14
HavingExpressionLexerSTAR = 15
HavingExpressionLexerSLASH = 16
HavingExpressionLexerPERCENT = 17
HavingExpressionLexerNOT = 18
HavingExpressionLexerAND = 19
HavingExpressionLexerOR = 20
HavingExpressionLexerIN = 21
HavingExpressionLexerBOOL = 22
HavingExpressionLexerNUMBER = 23
HavingExpressionLexerIDENTIFIER = 24
HavingExpressionLexerSTRING = 25
HavingExpressionLexerWS = 26
)

View File

@@ -0,0 +1,118 @@
// Code generated from grammar/HavingExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // HavingExpression
import "github.com/antlr4-go/antlr/v4"
// HavingExpressionListener is a complete listener for a parse tree produced by HavingExpressionParser.
type HavingExpressionListener interface {
antlr.ParseTreeListener
// EnterQuery is called when entering the query production.
EnterQuery(c *QueryContext)
// EnterExpression is called when entering the expression production.
EnterExpression(c *ExpressionContext)
// EnterOrExpression is called when entering the orExpression production.
EnterOrExpression(c *OrExpressionContext)
// EnterAndExpression is called when entering the andExpression production.
EnterAndExpression(c *AndExpressionContext)
// EnterPrimary is called when entering the primary production.
EnterPrimary(c *PrimaryContext)
// EnterComparison is called when entering the comparison production.
EnterComparison(c *ComparisonContext)
// EnterCompOp is called when entering the compOp production.
EnterCompOp(c *CompOpContext)
// EnterInList is called when entering the inList production.
EnterInList(c *InListContext)
// EnterSignedNumber is called when entering the signedNumber production.
EnterSignedNumber(c *SignedNumberContext)
// EnterOperand is called when entering the operand production.
EnterOperand(c *OperandContext)
// EnterTerm is called when entering the term production.
EnterTerm(c *TermContext)
// EnterFactor is called when entering the factor production.
EnterFactor(c *FactorContext)
// EnterAtom is called when entering the atom production.
EnterAtom(c *AtomContext)
// EnterFunctionCall is called when entering the functionCall production.
EnterFunctionCall(c *FunctionCallContext)
// EnterFunctionArgList is called when entering the functionArgList production.
EnterFunctionArgList(c *FunctionArgListContext)
// EnterFuncArg is called when entering the funcArg production.
EnterFuncArg(c *FuncArgContext)
// EnterFuncArgToken is called when entering the funcArgToken production.
EnterFuncArgToken(c *FuncArgTokenContext)
// EnterIdentifier is called when entering the identifier production.
EnterIdentifier(c *IdentifierContext)
// ExitQuery is called when exiting the query production.
ExitQuery(c *QueryContext)
// ExitExpression is called when exiting the expression production.
ExitExpression(c *ExpressionContext)
// ExitOrExpression is called when exiting the orExpression production.
ExitOrExpression(c *OrExpressionContext)
// ExitAndExpression is called when exiting the andExpression production.
ExitAndExpression(c *AndExpressionContext)
// ExitPrimary is called when exiting the primary production.
ExitPrimary(c *PrimaryContext)
// ExitComparison is called when exiting the comparison production.
ExitComparison(c *ComparisonContext)
// ExitCompOp is called when exiting the compOp production.
ExitCompOp(c *CompOpContext)
// ExitInList is called when exiting the inList production.
ExitInList(c *InListContext)
// ExitSignedNumber is called when exiting the signedNumber production.
ExitSignedNumber(c *SignedNumberContext)
// ExitOperand is called when exiting the operand production.
ExitOperand(c *OperandContext)
// ExitTerm is called when exiting the term production.
ExitTerm(c *TermContext)
// ExitFactor is called when exiting the factor production.
ExitFactor(c *FactorContext)
// ExitAtom is called when exiting the atom production.
ExitAtom(c *AtomContext)
// ExitFunctionCall is called when exiting the functionCall production.
ExitFunctionCall(c *FunctionCallContext)
// ExitFunctionArgList is called when exiting the functionArgList production.
ExitFunctionArgList(c *FunctionArgListContext)
// ExitFuncArg is called when exiting the funcArg production.
ExitFuncArg(c *FuncArgContext)
// ExitFuncArgToken is called when exiting the funcArgToken production.
ExitFuncArgToken(c *FuncArgTokenContext)
// ExitIdentifier is called when exiting the identifier production.
ExitIdentifier(c *IdentifierContext)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
// Code generated from grammar/HavingExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // HavingExpression
import "github.com/antlr4-go/antlr/v4"
// A complete Visitor for a parse tree produced by HavingExpressionParser.
type HavingExpressionVisitor interface {
antlr.ParseTreeVisitor
// Visit a parse tree produced by HavingExpressionParser#query.
VisitQuery(ctx *QueryContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#expression.
VisitExpression(ctx *ExpressionContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#orExpression.
VisitOrExpression(ctx *OrExpressionContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#andExpression.
VisitAndExpression(ctx *AndExpressionContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#primary.
VisitPrimary(ctx *PrimaryContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#comparison.
VisitComparison(ctx *ComparisonContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#compOp.
VisitCompOp(ctx *CompOpContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#inList.
VisitInList(ctx *InListContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#signedNumber.
VisitSignedNumber(ctx *SignedNumberContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#operand.
VisitOperand(ctx *OperandContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#term.
VisitTerm(ctx *TermContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#factor.
VisitFactor(ctx *FactorContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#atom.
VisitAtom(ctx *AtomContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#functionCall.
VisitFunctionCall(ctx *FunctionCallContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#functionArgList.
VisitFunctionArgList(ctx *FunctionArgListContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#funcArg.
VisitFuncArg(ctx *FuncArgContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#funcArgToken.
VisitFuncArgToken(ctx *FuncArgTokenContext) interface{}
// Visit a parse tree produced by HavingExpressionParser#identifier.
VisitIdentifier(ctx *IdentifierContext) interface{}
}

View File

@@ -124,8 +124,12 @@ func (q *querier) postProcessResults(ctx context.Context, results map[string]any
continue
}
stepInterval, err := req.StepIntervalForQuery(name)
if err != nil {
return nil, err
}
funcs := []qbtypes.Function{{Name: qbtypes.FunctionNameFillZero}}
funcs = q.prepareFillZeroArgsWithStep(funcs, req, req.StepIntervalForQuery(name))
funcs = q.prepareFillZeroArgsWithStep(funcs, req, stepInterval)
// empty time series if it doesn't exist
tsData, ok := typedResults[name].Value.(*qbtypes.TimeSeriesData)
if !ok {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
gomaps "maps"
"slices"
"strconv"
"strings"
@@ -282,6 +283,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
missingMetrics := []string{}
missingMetricQueries := []string{}
for _, query := range req.CompositeQuery.Queries {
var queryName string
@@ -374,6 +376,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
}
presentAggregations := []qbtypes.MetricAggregation{}
for i := range spec.Aggregations {
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
@@ -384,13 +387,18 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {
missingMetricQueries = append(missingMetricQueries, spec.Name)
continue
}
spec.Aggregations = presentAggregations
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
var bq *builderQuery[qbtypes.MetricAggregation]
@@ -409,25 +417,50 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
}
nonExistentMetrics := []string{}
var dormantMetricsWarningMsg string
if len(missingMetrics) > 0 {
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
for _, missingMetricName := range missingMetrics {
if ts, ok := lastSeenInfo[missingMetricName]; ok && ts > 0 {
continue
}
nonExistentMetrics = append(nonExistentMetrics, missingMetricName)
}
if len(nonExistentMetrics) == 1 {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
} else if len(nonExistentMetrics) > 1 {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
}
lastSeenStr := func(name string) string {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
return fmt.Sprintf("%s (last seen %s)", name, ago)
}
return name
return name // this case won't come cuz lastSeenStr is never called for metrics in nonExistentMetrics
}
if len(missingMetrics) == 1 {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
dormantMetricsWarningMsg = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
} else {
parts := make([]string, len(missingMetrics))
for i, m := range missingMetrics {
parts[i] = lastSeenStr(m)
}
dormantMetricsWarningMsg = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
}
parts := make([]string, len(missingMetrics))
for i, m := range missingMetrics {
parts[i] = lastSeenStr(m)
}
return nil, errors.NewNotFoundf(errors.CodeNotFound, "no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event)
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
@@ -440,6 +473,14 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
}
if dormantMetricsWarningMsg != "" {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: dormantMetricsWarningMsg,
})
}
}
return qbResp, qbErr
}
@@ -516,7 +557,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
})
queries[spec.Name] = bq
qbResp, qbErr := q.run(ctx, orgID, queries, req, nil, event)
qbResp, qbErr := q.run(ctx, orgID, queries, req, nil, event, nil)
if qbErr != nil {
client.Error <- qbErr
return
@@ -545,6 +586,7 @@ func (q *querier) run(
req *qbtypes.QueryRangeRequest,
steps map[string]qbtypes.Step,
qbEvent *qbtypes.QBEvent,
preseededResults map[string]any,
) (*qbtypes.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.PanelType: qbEvent.PanelType,
@@ -630,6 +672,7 @@ func (q *querier) run(
}
}
gomaps.Copy(results, preseededResults)
processedResults, err := q.postProcessResults(ctx, results, req)
if err != nil {
return nil, err

View File

@@ -299,6 +299,36 @@ type ApiResponse struct {
Error string `json:"error,omitempty"`
}
// toApiError translates a pkg/errors typed error into the legacy
// model.ApiError to preserve the v1 JSON response shape.
func toApiError(err error) *model.ApiError {
t, _, _, _, _, _ := errors.Unwrapb(err)
var typ model.ErrorType
switch t {
case errors.TypeInvalidInput:
typ = model.ErrorBadData
case errors.TypeNotFound:
typ = model.ErrorNotFound
case errors.TypeAlreadyExists:
typ = model.ErrorConflict
case errors.TypeUnauthenticated:
typ = model.ErrorUnauthorized
case errors.TypeForbidden:
typ = model.ErrorForbidden
case errors.TypeUnsupported:
typ = model.ErrorNotImplemented
case errors.TypeTimeout:
typ = model.ErrorTimeout
case errors.TypeCanceled:
typ = model.ErrorCanceled
default:
typ = model.ErrorInternal
}
return &model.ApiError{Typ: typ, Err: err}
}
// todo(remove): Implemented at render package (github.com/SigNoz/signoz/pkg/http/render) with the new error structure
func RespondError(w http.ResponseWriter, apiErr model.BaseApiError, data interface{}) {
json := jsoniter.ConfigCompatibleWithStandardLibrary
@@ -891,48 +921,6 @@ func (aH *APIHandler) getOverallStateTransitions(w http.ResponseWriter, r *http.
aH.Respond(w, stateItems)
}
func (aH *APIHandler) metaForLinks(ctx context.Context, rule *ruletypes.GettableRule) ([]v3.FilterItem, []v3.AttributeKey, map[string]v3.AttributeKey) {
filterItems := []v3.FilterItem{}
groupBy := []v3.AttributeKey{}
keys := make(map[string]v3.AttributeKey)
if rule.AlertType == ruletypes.AlertTypeLogs {
logFields, apiErr := aH.reader.GetLogFieldsFromNames(ctx, logsv3.GetFieldNames(rule.PostableRule.RuleCondition.CompositeQuery))
if apiErr == nil {
params := &v3.QueryRangeParamsV3{
CompositeQuery: rule.RuleCondition.CompositeQuery,
}
keys = model.GetLogFieldsV3(ctx, params, logFields)
} else {
aH.logger.ErrorContext(ctx, "failed to get log fields using empty keys", errors.Attr(apiErr))
}
} else if rule.AlertType == ruletypes.AlertTypeTraces {
traceFields, err := aH.reader.GetSpanAttributeKeysByNames(ctx, logsv3.GetFieldNames(rule.PostableRule.RuleCondition.CompositeQuery))
if err == nil {
keys = traceFields
} else {
aH.logger.ErrorContext(ctx, "failed to get span attributes using empty keys", errors.Attr(err))
}
}
if rule.AlertType == ruletypes.AlertTypeLogs || rule.AlertType == ruletypes.AlertTypeTraces {
if rule.RuleCondition.CompositeQuery != nil {
if rule.RuleCondition.QueryType() == v3.QueryTypeBuilder {
selectedQuery := rule.RuleCondition.GetSelectedQueryName()
if rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery] != nil &&
rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery].Filters != nil {
filterItems = rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery].Filters.Items
}
if rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery] != nil &&
rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery].GroupBy != nil {
groupBy = rule.RuleCondition.CompositeQuery.BuilderQueries[selectedQuery].GroupBy
}
}
}
}
return filterItems, groupBy, keys
}
func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
@@ -966,8 +954,6 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
if err != nil {
continue
}
filterItems, groupBy, keys := aH.metaForLinks(r.Context(), rule)
newFilters := contextlinks.PrepareFilters(lbls, filterItems, groupBy, keys)
end := time.Unix(res.Items[idx].UnixMilli/1000, 0)
// why are we subtracting 3 minutes?
// the query range is calculated based on the rule's evalWindow and evalDelay
@@ -975,54 +961,46 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
// to get the correct query range
start := end.Add(-rule.EvalWindow.Duration() - 3*time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
if rule.Version != "v5" {
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogs(start, end, newFilters)
} else {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
} else if rule.AlertType == ruletypes.AlertTypeTraces {
if rule.Version != "v5" {
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTraces(start, end, newFilters)
} else {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
}
}
}
@@ -1051,26 +1029,6 @@ func (aH *APIHandler) getRuleStateHistoryTopContributors(w http.ResponseWriter,
return
}
rule, err := aH.ruleManager.GetRule(r.Context(), id)
if err == nil {
for idx := range res {
lbls := make(map[string]string)
err := json.Unmarshal([]byte(res[idx].Labels), &lbls)
if err != nil {
continue
}
filterItems, groupBy, keys := aH.metaForLinks(r.Context(), rule)
newFilters := contextlinks.PrepareFilters(lbls, filterItems, groupBy, keys)
end := time.Unix(params.End/1000, 0)
start := time.Unix(params.Start/1000, 0)
if rule.AlertType == ruletypes.AlertTypeLogs {
res[idx].RelatedLogsLink = contextlinks.PrepareLinksToLogs(start, end, newFilters)
} else if rule.AlertType == ruletypes.AlertTypeTraces {
res[idx].RelatedTracesLink = contextlinks.PrepareLinksToTraces(start, end, newFilters)
}
}
}
aH.Respond(w, res)
}
@@ -1301,9 +1259,9 @@ func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
alertCount, apiRrr := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if apiRrr != nil {
RespondError(w, apiRrr, nil)
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
@@ -1325,7 +1283,7 @@ func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
RespondError(w, toApiError(err), nil)
return
}
@@ -1357,7 +1315,7 @@ func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
RespondError(w, toApiError(err), nil)
return
}
@@ -1387,7 +1345,7 @@ func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
RespondError(w, toApiError(err), nil)
return
}
@@ -1407,7 +1365,7 @@ func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
RespondError(w, toApiError(err), nil)
return
}

View File

@@ -345,7 +345,6 @@ func makeRulesManager(
MetadataStore: metadataStore,
Prometheus: prometheus,
Context: context.Background(),
Reader: ch,
Querier: querier,
Logger: providerSettings.Logger,
Cache: cache,
@@ -354,7 +353,7 @@ func makeRulesManager(
Alertmanager: alertmanager,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
SQLStore: sqlstore,
QueryParser: queryParser,
RuleStateHistoryModule: ruleStateHistoryModule,
}

View File

@@ -2,10 +2,8 @@ package common
import (
"math"
"regexp"
"sort"
"time"
"unicode"
"github.com/SigNoz/signoz/pkg/query-service/constants"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
@@ -76,23 +74,6 @@ func LCMList(nums []int64) int64 {
return result
}
func NormalizeLabelName(name string) string {
// See https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
// Regular expression to match non-alphanumeric characters except underscores
reg := regexp.MustCompile(`[^a-zA-Z0-9_]`)
// Replace all non-alphanumeric characters except underscores with underscores
normalized := reg.ReplaceAllString(name, "_")
// If the first character is not a letter or an underscore, prepend an underscore
if len(normalized) > 0 && !unicode.IsLetter(rune(normalized[0])) && normalized[0] != '_' {
normalized = "_" + normalized
}
return normalized
}
func GetSeriesFromCachedData(data []querycache.CachedSeriesData, start, end int64) []*v3.Series {
series := make(map[uint64]*v3.Series)

View File

@@ -14,10 +14,11 @@ func ApplyHavingClause(result []*v3.Result, queryRangeParams *v3.QueryRangeParam
builderQueries := queryRangeParams.CompositeQuery.BuilderQueries
// apply having clause for metrics and formula
if builderQueries != nil &&
(builderQueries[result.QueryName].DataSource == v3.DataSourceMetrics ||
builderQueries[result.QueryName].QueryName != builderQueries[result.QueryName].Expression) {
havingClause := builderQueries[result.QueryName].Having
builderQuery := builderQueries[result.QueryName]
if builderQuery != nil &&
(builderQuery.DataSource == v3.DataSourceMetrics ||
builderQuery.QueryName != builderQuery.Expression) {
havingClause := builderQuery.Having
for i := 0; i < len(result.Series); i++ {
for j := 0; j < len(result.Series[i].Points); j++ {

View File

@@ -312,6 +312,72 @@ func TestApplyHavingCaluse(t *testing.T) {
},
},
},
{
name: "query not in builder queries should not panic",
results: []*v3.Result{
{
QueryName: "A",
Series: []*v3.Series{
{
Points: []v3.Point{
{Value: 1.0},
{Value: 2.0},
},
},
},
},
},
params: &v3.QueryRangeParamsV3{
CompositeQuery: &v3.CompositeQuery{
BuilderQueries: map[string]*v3.BuilderQuery{},
},
},
want: []*v3.Result{
{
QueryName: "A",
Series: []*v3.Series{
{
Points: []v3.Point{
{Value: 1.0},
{Value: 2.0},
},
},
},
},
},
},
{
name: "nil builder queries should not panic",
results: []*v3.Result{
{
QueryName: "A",
Series: []*v3.Series{
{
Points: []v3.Point{
{Value: 1.0},
},
},
},
},
},
params: &v3.QueryRangeParamsV3{
CompositeQuery: &v3.CompositeQuery{
BuilderQueries: nil,
},
},
want: []*v3.Result{
{
QueryName: "A",
Series: []*v3.Series{
{
Points: []v3.Point{
{Value: 1.0},
},
},
},
},
},
},
}
for _, tc := range testCases {

View File

@@ -9,12 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
qslabels "github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -51,8 +45,8 @@ type BaseRule struct {
// holds the static set of labels and annotations for the rule
// these are the same for all alerts created for this rule
labels qslabels.BaseLabels
annotations qslabels.BaseLabels
labels ruletypes.Labels
annotations ruletypes.Labels
// preferredChannels is the list of channels to send the alert to
// if the rule is triggered
preferredChannels []string
@@ -71,8 +65,6 @@ type BaseRule struct {
// This is used for missing data alerts.
lastTimestampWithDatapoints time.Time
reader interfaces.Reader
logger *slog.Logger
// sendUnmatched sends observed metric values even if they don't match the
@@ -82,12 +74,6 @@ type BaseRule struct {
// sendAlways will send alert irrespective of resendDelay or other params
sendAlways bool
// TemporalityMap is a map of metric name to temporality to avoid fetching
// temporality for the same metric multiple times.
// Querying the v4 table on low cardinal temporality column should be fast,
// but we can still avoid the query if we have the data in memory.
TemporalityMap map[string]map[v3.Temporality]bool
sqlstore sqlstore.SQLStore
metadataStore telemetrytypes.MetadataStore
@@ -152,9 +138,9 @@ func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
}
}
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader interfaces.Reader, opts ...RuleOption) (*BaseRule, error) {
func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, opts ...RuleOption) (*BaseRule, error) {
if p.RuleCondition == nil || !p.RuleCondition.IsValid() {
return nil, fmt.Errorf("invalid rule condition")
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule condition")
}
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
@@ -173,13 +159,11 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
labels: qslabels.FromMap(p.Labels),
annotations: qslabels.FromMap(p.Annotations),
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
preferredChannels: p.PreferredChannels,
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
reader: reader,
TemporalityMap: make(map[string]map[v3.Temporality]bool),
Threshold: threshold,
evaluation: evaluation,
}
@@ -200,20 +184,6 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
return baseRule, nil
}
func (r *BaseRule) matchType() ruletypes.MatchType {
if r.ruleCondition == nil {
return ruletypes.AtleastOnce
}
return r.ruleCondition.MatchType
}
func (r *BaseRule) compareOp() ruletypes.CompareOp {
if r.ruleCondition == nil {
return ruletypes.ValueIsEq
}
return r.ruleCondition.CompareOp
}
func (r *BaseRule) currentAlerts() []*ruletypes.Alert {
r.mtx.Lock()
defer r.mtx.Unlock()
@@ -245,10 +215,10 @@ func (r *BaseRule) ActiveAlertsLabelFP() map[uint64]struct{} {
activeAlerts := make(map[uint64]struct{}, len(r.Active))
for _, alert := range r.Active {
if alert == nil || alert.QueryResultLables == nil {
if alert == nil || alert.QueryResultLabels == nil {
continue
}
activeAlerts[alert.QueryResultLables.Hash()] = struct{}{}
activeAlerts[alert.QueryResultLabels.Hash()] = struct{}{}
}
return activeAlerts
}
@@ -269,19 +239,24 @@ func (r *BaseRule) ID() string { return r.id }
func (r *BaseRule) OrgID() valuer.UUID { return r.orgID }
func (r *BaseRule) Name() string { return r.name }
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
func (r *BaseRule) Labels() qslabels.BaseLabels { return r.labels }
func (r *BaseRule) Annotations() qslabels.BaseLabels { return r.annotations }
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
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)
}
func (r *BaseRule) Unit() string {
if r.ruleCondition != nil && r.ruleCondition.CompositeQuery != nil {
return r.ruleCondition.CompositeQuery.Unit
func (r *BaseRule) SelectedQuery(ctx context.Context) string {
if r.ruleCondition.SelectedQuery != "" {
return r.ruleCondition.SelectedQuery
}
return ""
r.logger.WarnContext(ctx, "missing selected query", slog.String("rule.id", r.ID()))
return r.ruleCondition.SelectedQueryName()
}
func (r *BaseRule) Unit() string {
return r.ruleCondition.CompositeQuery.Unit
}
func (r *BaseRule) Timestamps(ts time.Time) (time.Time, time.Time) {
@@ -348,10 +323,10 @@ func (r *BaseRule) GetEvaluationTimestamp() time.Time {
return r.evaluationTimestamp
}
func (r *BaseRule) State() model.AlertState {
maxState := model.StateInactive
func (r *BaseRule) State() ruletypes.AlertState {
maxState := ruletypes.StateInactive
for _, a := range r.Active {
if a.State > maxState {
if a.State.Severity() > maxState.Severity() {
maxState = a.State
}
}
@@ -408,13 +383,13 @@ func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
}
}
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []model.RuleStateHistory) error {
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState ruletypes.AlertState, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
if r.ruleStateHistoryModule == nil {
return nil
}
if err := r.ruleStateHistoryModule.RecordRuleStateHistory(ctx, r.ID(), r.handledRestart, toRuleStateHistoryTypes(itemsToAdd)); err != nil {
r.logger.ErrorContext(ctx, "error while recording rule state history", errors.Attr(err), slog.Any("itemsToAdd", itemsToAdd))
if err := r.ruleStateHistoryModule.RecordRuleStateHistory(ctx, r.ID(), r.handledRestart, itemsToAdd); err != nil {
r.logger.ErrorContext(ctx, "error while recording rule state history", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
return err
}
r.handledRestart = true
@@ -422,100 +397,6 @@ func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, curren
return nil
}
// TODO(srikanthccv): remove these when v3 is cleaned up
func toRuleStateHistoryTypes(entries []model.RuleStateHistory) []rulestatehistorytypes.RuleStateHistory {
converted := make([]rulestatehistorytypes.RuleStateHistory, 0, len(entries))
for _, entry := range entries {
converted = append(converted, rulestatehistorytypes.RuleStateHistory{
RuleID: entry.RuleID,
RuleName: entry.RuleName,
OverallState: toRuleStateHistoryAlertState(entry.OverallState),
OverallStateChanged: entry.OverallStateChanged,
State: toRuleStateHistoryAlertState(entry.State),
StateChanged: entry.StateChanged,
UnixMilli: entry.UnixMilli,
Labels: rulestatehistorytypes.LabelsString(entry.Labels),
Fingerprint: entry.Fingerprint,
Value: entry.Value,
})
}
return converted
}
func toRuleStateHistoryAlertState(state model.AlertState) rulestatehistorytypes.AlertState {
switch state {
case model.StateInactive:
return rulestatehistorytypes.StateInactive
case model.StatePending:
return rulestatehistorytypes.StatePending
case model.StateRecovering:
return rulestatehistorytypes.StateRecovering
case model.StateFiring:
return rulestatehistorytypes.StateFiring
case model.StateNoData:
return rulestatehistorytypes.StateNoData
case model.StateDisabled:
return rulestatehistorytypes.StateDisabled
default:
return rulestatehistorytypes.StateInactive
}
}
func (r *BaseRule) PopulateTemporality(ctx context.Context, orgID valuer.UUID, qp *v3.QueryRangeParamsV3) error {
missingTemporality := make([]string, 0)
metricNameToTemporality := make(map[string]map[v3.Temporality]bool)
if qp.CompositeQuery != nil && len(qp.CompositeQuery.BuilderQueries) > 0 {
for _, query := range qp.CompositeQuery.BuilderQueries {
// if there is no temporality specified in the query but we have it in the map
// then use the value from the map
if query.Temporality == "" && r.TemporalityMap[query.AggregateAttribute.Key] != nil {
// We prefer delta if it is available
if r.TemporalityMap[query.AggregateAttribute.Key][v3.Delta] {
query.Temporality = v3.Delta
} else if r.TemporalityMap[query.AggregateAttribute.Key][v3.Cumulative] {
query.Temporality = v3.Cumulative
} else {
query.Temporality = v3.Unspecified
}
}
// we don't have temporality for this metric
if query.DataSource == v3.DataSourceMetrics && query.Temporality == "" {
missingTemporality = append(missingTemporality, query.AggregateAttribute.Key)
}
if _, ok := metricNameToTemporality[query.AggregateAttribute.Key]; !ok {
metricNameToTemporality[query.AggregateAttribute.Key] = make(map[v3.Temporality]bool)
}
}
}
var nameToTemporality map[string]map[v3.Temporality]bool
var err error
if len(missingTemporality) > 0 {
nameToTemporality, err = r.reader.FetchTemporality(ctx, orgID, missingTemporality)
if err != nil {
return err
}
}
if qp.CompositeQuery != nil && len(qp.CompositeQuery.BuilderQueries) > 0 {
for name := range qp.CompositeQuery.BuilderQueries {
query := qp.CompositeQuery.BuilderQueries[name]
if query.DataSource == v3.DataSourceMetrics && query.Temporality == "" {
if nameToTemporality[query.AggregateAttribute.Key][v3.Delta] {
query.Temporality = v3.Delta
} else if nameToTemporality[query.AggregateAttribute.Key][v3.Cumulative] {
query.Temporality = v3.Cumulative
} else {
query.Temporality = v3.Unspecified
}
r.TemporalityMap[query.AggregateAttribute.Key] = nameToTemporality[query.AggregateAttribute.Key]
}
}
}
return nil
}
// ShouldSkipNewGroups returns true if new group filtering should be applied
func (r *BaseRule) ShouldSkipNewGroups() bool {
return r.newGroupEvalDelay.IsPositive()
@@ -523,7 +404,7 @@ func (r *BaseRule) ShouldSkipNewGroups() bool {
// isFilterNewSeriesSupported checks if the query is supported for new series filtering
func (r *BaseRule) isFilterNewSeriesSupported() bool {
if r.ruleCondition.CompositeQuery.QueryType == v3.QueryTypeBuilder {
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
for _, query := range r.ruleCondition.CompositeQuery.Queries {
if query.Type != qbtypes.QueryTypeBuilder {
continue
@@ -592,7 +473,7 @@ func (r *BaseRule) extractMetricAndGroupBys(ctx context.Context) (map[string][]s
// FilterNewSeries filters out items that are too new based on metadata first_seen timestamps.
// Returns the filtered series (old ones) excluding new series that are still within the grace period.
func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*v3.Series) ([]*v3.Series, error) {
func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
// Extract metric names and groupBy keys
metricToGroupedFields, err := r.extractMetricAndGroupBys(ctx)
if err != nil {
@@ -609,14 +490,22 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
seriesIdxToLookupKeys := make(map[int][]telemetrytypes.MetricMetadataLookupKey) // series index -> lookup keys
for i := 0; i < len(series); i++ {
metricLabelMap := series[i].Labels
valueForKey := func(key string) (string, bool) {
for _, item := range series[i].Labels {
if item.Key.Name == key {
return fmt.Sprint(item.Value), true
}
}
return "", false
}
// Collect groupBy attribute-value pairs for this series
seriesKeys := make([]telemetrytypes.MetricMetadataLookupKey, 0)
for metricName, groupedFields := range metricToGroupedFields {
for _, groupByKey := range groupedFields {
if attrValue, ok := metricLabelMap[groupByKey]; ok {
if attrValue, ok := valueForKey(groupByKey); ok {
lookupKey := telemetrytypes.MetricMetadataLookupKey{
MetricName: metricName,
AttributeName: groupByKey,
@@ -656,7 +545,7 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
}
// Filter series based on first_seen + delay
filteredSeries := make([]*v3.Series, 0, len(series))
filteredSeries := make([]*qbtypes.TimeSeries, 0, len(series))
evalTimeMs := ts.UnixMilli()
newGroupEvalDelayMs := r.newGroupEvalDelay.Milliseconds()
@@ -694,7 +583,7 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
// Check if first_seen + delay has passed
if maxFirstSeen+newGroupEvalDelayMs > evalTimeMs {
// Still within grace period, skip this series
r.logger.InfoContext(ctx, "Skipping new series", "rule_name", r.Name(), "series_idx", i, "max_first_seen", maxFirstSeen, "eval_time_ms", evalTimeMs, "delay_ms", newGroupEvalDelayMs, "labels", series[i].Labels)
r.logger.InfoContext(ctx, "skipping new series", slog.String("rule.id", r.ID()), slog.Int("series.index", i), slog.Int64("series.max_first_seen", maxFirstSeen), slog.Int64("eval.time_ms", evalTimeMs), slog.Int64("eval.delay_ms", newGroupEvalDelayMs), slog.Any("series.labels", series[i].Labels))
continue
}
@@ -704,7 +593,7 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
skippedCount := len(series) - len(filteredSeries)
if skippedCount > 0 {
r.logger.InfoContext(ctx, "Filtered new series", "rule_name", r.Name(), "skipped_count", skippedCount, "total_count", len(series), "delay_ms", newGroupEvalDelayMs)
r.logger.InfoContext(ctx, "filtered new series", slog.String("rule.id", r.ID()), slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)), slog.Int64("eval.delay_ms", newGroupEvalDelayMs))
}
return filteredSeries, nil
@@ -725,10 +614,10 @@ func (r *BaseRule) HandleMissingDataAlert(ctx context.Context, ts time.Time, has
return nil
}
r.logger.InfoContext(ctx, "no data found for rule condition", "rule_id", r.ID())
lbls := labels.NewBuilder(labels.Labels{})
r.logger.InfoContext(ctx, "no data found for rule condition", slog.String("rule.id", r.ID()))
lbls := ruletypes.NewBuilder()
if !r.lastTimestampWithDatapoints.IsZero() {
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(constants.AlertTimeFormat))
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(ruletypes.AlertTimeFormat))
}
return &ruletypes.Sample{Metric: lbls.Labels(), IsMissing: true}
}

View File

@@ -2,23 +2,14 @@ package rules
import (
"context"
"log/slog"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/cachetest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -27,22 +18,29 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
// createTestSeries creates a *v3.Series with the given labels and optional points
// 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
func createTestSeries(labels map[string]string, points []v3.Point) *v3.Series {
if points == nil {
points = []v3.Point{}
func createTestSeries(kvMap map[string]string, values []*qbtypes.TimeSeriesValue) *qbtypes.TimeSeries {
if values == nil {
values = []*qbtypes.TimeSeriesValue{}
}
return &v3.Series{
Labels: labels,
Points: points,
lbls := make([]*qbtypes.Label, 0)
for k, v := range kvMap {
lbls = append(lbls, &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: k},
Value: v,
})
}
return &qbtypes.TimeSeries{
Labels: lbls,
Values: values,
}
}
// seriesEqual compares two v3.Series by their labels
// Returns true if the series have the same labels (order doesn't matter)
func seriesEqual(s1, s2 *v3.Series) bool {
func seriesEqual(s1, s2 *qbtypes.TimeSeries) bool {
if s1 == nil && s2 == nil {
return true
}
@@ -117,7 +115,7 @@ func mergeFirstSeenMaps(maps ...map[telemetrytypes.MetricMetadataLookupKey]int64
}
// createPostableRule creates a PostableRule with the given CompositeQuery
func createPostableRule(compositeQuery *v3.CompositeQuery) ruletypes.PostableRule {
func createPostableRule(compositeQuery *ruletypes.AlertCompositeQuery) ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "Test Rule",
AlertType: ruletypes.AlertTypeMetric,
@@ -135,10 +133,10 @@ func createPostableRule(compositeQuery *v3.CompositeQuery) ruletypes.PostableRul
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "test-threshold",
TargetValue: func() *float64 { v := 1.0; return &v }(),
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Name: "test-threshold",
TargetValue: func() *float64 { v := 1.0; return &v }(),
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
},
},
},
@@ -149,12 +147,12 @@ func createPostableRule(compositeQuery *v3.CompositeQuery) ruletypes.PostableRul
// filterNewSeriesTestCase represents a test case for FilterNewSeries
type filterNewSeriesTestCase struct {
name string
compositeQuery *v3.CompositeQuery
series []*v3.Series
compositeQuery *ruletypes.AlertCompositeQuery
series []*qbtypes.TimeSeries
firstSeenMap map[telemetrytypes.MetricMetadataLookupKey]int64
newGroupEvalDelay valuer.TextDuration
evalTime time.Time
expectedFiltered []*v3.Series // series that should be in the final filtered result (old enough)
expectedFiltered []*qbtypes.TimeSeries // series that should be in the final filtered result (old enough)
expectError bool
}
@@ -170,8 +168,8 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
tests := []filterNewSeriesTestCase{
{
name: "mixed old and new series - Builder query",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -194,7 +192,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-new", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-missing", "env": "stage"}, nil),
@@ -206,15 +204,15 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-missing", "env": "stage"}, nil),
}, // svc-old and svc-missing should be included; svc-new is filtered out
},
{
name: "all new series - PromQL query",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
@@ -228,7 +226,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-new1", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-new2", "env": "stage"}, nil),
},
@@ -238,12 +236,12 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{}, // all should be filtered out (new series)
expectedFiltered: []*qbtypes.TimeSeries{}, // all should be filtered out (new series)
},
{
name: "all old series - ClickHouse query",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeClickHouseSQL,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeClickHouseSQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
@@ -255,7 +253,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old1", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-old2", "env": "stage"}, nil),
},
@@ -265,15 +263,15 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old1", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-old2", "env": "stage"}, nil),
}, // all should be included (old series)
},
{
name: "no grouping in query - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -293,20 +291,20 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
}, // early return, no filtering - all series included
},
{
name: "no metric names - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -323,20 +321,20 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
}, // early return, no filtering - all series included
},
{
name: "series with no matching labels - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -359,20 +357,20 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"status": "200"}, nil), // no service_name or env
},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"status": "200"}, nil),
}, // series included as we can't decide if it's new or old
},
{
name: "series with missing metadata - PromQL",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
@@ -386,7 +384,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-no-metadata", "env": "prod"}, nil),
},
@@ -394,15 +392,15 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
// svc-no-metadata has no entry in firstSeenMap
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-old", "env": "prod"}, nil),
createTestSeries(map[string]string{"service_name": "svc-no-metadata", "env": "prod"}, nil),
}, // both should be included - svc-old is old, svc-no-metadata can't be decided
},
{
name: "series with partial metadata - ClickHouse",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeClickHouseSQL,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeClickHouseSQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
@@ -414,7 +412,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-partial", "env": "prod"}, nil),
},
// Only provide metadata for service_name, not env
@@ -424,14 +422,14 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc-partial", "env": "prod"}, nil),
}, // has some metadata, uses max first_seen which is old
},
{
name: "empty series array - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -454,16 +452,16 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{},
series: []*qbtypes.TimeSeries{},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{},
expectedFiltered: []*qbtypes.TimeSeries{},
},
{
name: "zero delay - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -486,20 +484,20 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
firstSeenMap: createFirstSeenMap("request_total", defaultGroupByFields, defaultEvalTime, defaultDelay, true, "svc1", "prod"),
newGroupEvalDelay: valuer.TextDuration{}, // zero delay
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
}, // with zero delay, all series pass
},
{
name: "multiple metrics with same groupBy keys - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -527,7 +525,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
firstSeenMap: mergeFirstSeenMaps(
@@ -536,14 +534,14 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
},
{
name: "series with multiple groupBy attributes where one is new and one is old - Builder",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -566,7 +564,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1", "env": "prod"}, nil),
},
// service_name is old, env is new - should use max (new)
@@ -576,12 +574,12 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{}, // max first_seen is new, so should be filtered out
expectedFiltered: []*qbtypes.TimeSeries{}, // max first_seen is new, so should be filtered out
},
{
name: "Logs query - should skip filtering and return empty skip indexes",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -601,22 +599,22 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
}, // Logs queries should return early, no filtering - all included
},
{
name: "Traces query - should skip filtering and return empty skip indexes",
compositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
compositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -636,14 +634,14 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
},
},
},
series: []*v3.Series{
series: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
},
firstSeenMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
newGroupEvalDelay: defaultNewGroupEvalDelay,
evalTime: defaultEvalTime,
expectedFiltered: []*v3.Series{
expectedFiltered: []*qbtypes.TimeSeries{
createTestSeries(map[string]string{"service_name": "svc1"}, nil),
createTestSeries(map[string]string{"service_name": "svc2"}, nil),
}, // Traces queries should return early, no filtering - all included
@@ -655,9 +653,6 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
// Create postableRule from compositeQuery
postableRule := createPostableRule(tt.compositeQuery)
// Setup telemetry store mock
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
// Setup mock metadata store
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
@@ -681,37 +676,12 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
// Setup metadata query mock
mockMetadataStore.SetFirstSeenFromMetricMetadata(tt.firstSeenMap)
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(
cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
},
},
)
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), settings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Second,
nil,
readerCache,
options,
)
postableRule.NotificationSettings = &ruletypes.NotificationSettings{
NewGroupEvalDelay: tt.newGroupEvalDelay,
}
// Create BaseRule using NewBaseRule
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, reader, WithQueryParser(queryParser), WithLogger(logger), WithMetadataStore(mockMetadataStore))
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, WithQueryParser(queryParser), WithLogger(logger), WithMetadataStore(mockMetadataStore))
require.NoError(t, err)
filteredSeries, err := rule.FilterNewSeries(context.Background(), tt.evalTime, tt.series)
@@ -755,9 +725,13 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
// labelsKey creates a deterministic string key from a labels map
// This is used to group series by their unique label combinations
func labelsKey(lbls map[string]string) string {
func labelsKey(lbls []*qbtypes.Label) string {
if len(lbls) == 0 {
return ""
}
return labels.FromMap(lbls).String()
temp := ruletypes.NewBuilder()
for _, item := range lbls {
temp.Set(item.Key.Name, fmt.Sprint(item.Value))
}
return temp.Labels().String()
}

View File

@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/go-openapi/strfmt"
@@ -21,9 +21,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/prometheus"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
@@ -39,8 +36,7 @@ type PrepareTaskOptions struct {
TaskName string
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Reader interfaces.Reader
Querier querierV5.Querier
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
@@ -53,8 +49,7 @@ type PrepareTestRuleOptions struct {
Rule *ruletypes.PostableRule
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Reader interfaces.Reader
Querier querierV5.Querier
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
@@ -65,19 +60,12 @@ type PrepareTestRuleOptions struct {
const taskNameSuffix = "webAppEditor"
func RuleIdFromTaskName(n string) string {
func RuleIDFromTaskName(n string) string {
return strings.Split(n, "-groupname")[0]
}
func prepareTaskName(ruleId interface{}) string {
switch ruleId.(type) {
case int, int64:
return fmt.Sprintf("%d-groupname", ruleId)
case string:
return fmt.Sprintf("%s-groupname", ruleId)
default:
return fmt.Sprintf("%v-groupname", ruleId)
}
func prepareTaskName(ruleID string) string {
return fmt.Sprintf("%s-groupname", ruleID)
}
// ManagerOptions bundles options for the Manager.
@@ -88,8 +76,7 @@ type ManagerOptions struct {
Context context.Context
ResendDelay time.Duration
Reader interfaces.Reader
Querier querierV5.Querier
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
@@ -98,12 +85,12 @@ type ManagerOptions struct {
RuleStateHistoryModule rulestatehistory.Module
PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
PrepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, error)
Alertmanager alertmanager.Alertmanager
OrgGetter organization.Getter
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
SqlStore sqlstore.SQLStore
SQLStore sqlstore.SQLStore
QueryParser queryparser.QueryParser
}
@@ -119,10 +106,9 @@ type Manager struct {
maintenanceStore ruletypes.MaintenanceStore
logger *slog.Logger
reader interfaces.Reader
cache cache.Cache
prepareTaskFunc func(opts PrepareTaskOptions) (Task, error)
prepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, *model.ApiError)
prepareTestRuleFunc func(opts PrepareTestRuleOptions) (int, error)
alertmanager alertmanager.Alertmanager
sqlstore sqlstore.SQLStore
@@ -152,7 +138,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules := make([]Rule, 0)
var task Task
ruleId := RuleIdFromTaskName(opts.TaskName)
ruleID := RuleIDFromTaskName(opts.TaskName)
evaluation, err := opts.Rule.Evaluation.GetEvaluation()
if err != nil {
@@ -162,10 +148,9 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := NewThresholdRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Querier,
opts.Logger,
WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -188,11 +173,10 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
// create promql rule
pr, err := NewPromRule(
ruleId,
ruleID,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
WithSQLStore(opts.SQLStore),
WithQueryParser(opts.ManagerOpts.QueryParser),
@@ -210,7 +194,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
}
return task, nil
@@ -229,13 +213,12 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
opts: o,
block: make(chan struct{}),
logger: o.Logger,
reader: o.Reader,
cache: o.Cache,
prepareTaskFunc: o.PrepareTaskFunc,
prepareTestRuleFunc: o.PrepareTestRuleFunc,
alertmanager: o.Alertmanager,
orgGetter: o.OrgGetter,
sqlstore: o.SqlStore,
sqlstore: o.SQLStore,
queryParser: o.QueryParser,
}
@@ -292,12 +275,13 @@ func (m *Manager) initiate(ctx context.Context) error {
loadErrors = append(loadErrors, err)
continue
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, org.ID, rec.ID.StringValue(), &config)
if err != nil {
loadErrors = append(loadErrors, err)
m.logger.InfoContext(ctx, "failed to set rule notification config", "rule_id", rec.ID.StringValue())
m.logger.WarnContext(ctx, "failed to set rule notification config", slog.String("rule.id", rec.ID.StringValue()), errors.Attr(err))
}
}
if !parsedRule.Disabled {
@@ -413,7 +397,6 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Reader: m.reader,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
@@ -460,8 +443,8 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
id, err := valuer.NewUUID(idStr)
if err != nil {
m.logger.Error("delete rule received a rule id in invalid format, must be a valid uuid-v7", "id", idStr, errors.Attr(err))
return fmt.Errorf("delete rule received an rule id in invalid format, must be a valid uuid-v7")
m.logger.ErrorContext(ctx, "delete rule received a rule id in invalid format, must be a valid uuid-v7", slog.String("rule.id", idStr), errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "delete rule received an rule id in invalid format, must be a valid uuid-v7")
}
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -526,7 +509,7 @@ func (m *Manager) deleteTask(taskName string) {
if ok {
oldg.Stop()
delete(m.tasks, taskName)
delete(m.rules, RuleIdFromTaskName(taskName))
delete(m.rules, RuleIDFromTaskName(taskName))
m.logger.Debug("rule task deleted", "name", taskName)
} else {
m.logger.Info("rule not found for deletion", "name", taskName)
@@ -622,7 +605,6 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Reader: m.reader,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
@@ -644,7 +626,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
// If there is another task with the same identifier, raise an error
_, ok := m.tasks[taskName]
if ok {
return fmt.Errorf("a rule with the same name already exists")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "a rule with the same name already exists")
}
go func() {
@@ -766,7 +748,7 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
if len(alerts) == 0 {
return
}
ruleID := alerts[0].Labels.Map()[labels.AlertRuleIdLabel]
ruleID := alerts[0].Labels.Map()[ruletypes.AlertRuleIDLabel]
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
@@ -775,7 +757,7 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
a.Annotations = alert.Annotations.Map()
a.StartsAt = strfmt.DateTime(alert.FiredAt)
labelsMap := alert.Labels.Map()
labelsMap[labels.TestAlertLabel] = "true"
labelsMap[ruletypes.TestAlertLabel] = "true"
a.Alert = alertmanagertypes.AlertModel{
Labels: labelsMap,
GeneratorURL: strfmt.URI(generatorURL),
@@ -824,7 +806,7 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
ruleResponse := ruletypes.GettableRule{}
err = json.Unmarshal([]byte(s.Data), &ruleResponse)
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", "id", s.ID.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", s.ID.StringValue()), errors.Attr(err))
continue
}
@@ -832,7 +814,7 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
// fetch state of rule from memory
if rm, ok := m.rules[ruleResponse.Id]; !ok {
ruleResponse.State = model.StateDisabled
ruleResponse.State = ruletypes.StateDisabled
ruleResponse.Disabled = true
} else {
ruleResponse.State = rm.State()
@@ -855,13 +837,13 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
r := ruletypes.GettableRule{}
err = json.Unmarshal([]byte(s.Data), &r)
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", "id", s.ID.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", s.ID.StringValue()), errors.Attr(err))
return nil, err
}
r.Id = id.StringValue()
// fetch state of rule from memory
if rm, ok := m.rules[r.Id]; !ok {
r.State = model.StateDisabled
r.State = ruletypes.StateDisabled
r.Disabled = true
} else {
r.State = rm.State()
@@ -924,30 +906,30 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// retrieve rule from DB
storedJSON, err := m.ruleStore.GetStoredRule(ctx, id)
if err != nil {
m.logger.ErrorContext(ctx, "failed to get stored rule with given id", "id", id.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to get stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
storedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", "id", id.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", "id", id.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
// deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", "task_name", taskName, errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
return nil, err
}
newStoredJson, err := json.Marshal(&storedRule)
if err != nil {
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", "id", id.StringValue(), errors.Attr(err))
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
@@ -972,7 +954,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// fetch state of rule from memory
if rm, ok := m.rules[id.StringValue()]; !ok {
response.State = model.StateDisabled
response.State = ruletypes.StateDisabled
response.Disabled = true
} else {
response.State = rm.State()
@@ -983,11 +965,11 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, *model.ApiError) {
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
parsedRule := ruletypes.PostableRule{}
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
if err != nil {
return 0, model.BadRequest(err)
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
}
if !parsedRule.NotificationSettings.UsePolicy {
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
@@ -995,17 +977,13 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName, &config)
if err != nil {
return 0, &model.ApiError{
Typ: model.ErrorBadData,
Err: err,
}
return 0, err
}
alertCount, apiErr := m.prepareTestRuleFunc(PrepareTestRuleOptions{
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
Rule: &parsedRule,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Reader: m.reader,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
@@ -1015,83 +993,5 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
OrgID: orgID,
})
return alertCount, apiErr
}
func (m *Manager) GetAlertDetailsForMetricNames(ctx context.Context, metricNames []string) (map[string][]ruletypes.GettableRule, *model.ApiError) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
result := make(map[string][]ruletypes.GettableRule)
rules, err := m.ruleStore.GetStoredRules(ctx, claims.OrgID)
if err != nil {
m.logger.ErrorContext(ctx, "error getting stored rules", errors.Attr(err))
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
metricRulesMap := make(map[string][]ruletypes.GettableRule)
for _, storedRule := range rules {
var rule ruletypes.GettableRule
err = json.Unmarshal([]byte(storedRule.Data), &rule)
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", "id", storedRule.ID.StringValue(), errors.Attr(err))
continue
}
if rule.AlertType != ruletypes.AlertTypeMetric || rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
continue
}
rule.Id = storedRule.ID.StringValue()
rule.CreatedAt = &storedRule.CreatedAt
rule.CreatedBy = &storedRule.CreatedBy
rule.UpdatedAt = &storedRule.UpdatedAt
rule.UpdatedBy = &storedRule.UpdatedBy
for _, query := range rule.RuleCondition.CompositeQuery.BuilderQueries {
if query.AggregateAttribute.Key != "" {
metricRulesMap[query.AggregateAttribute.Key] = append(metricRulesMap[query.AggregateAttribute.Key], rule)
}
}
for _, query := range rule.RuleCondition.CompositeQuery.PromQueries {
if query.Query != "" {
for _, metricName := range metricNames {
if strings.Contains(query.Query, metricName) {
metricRulesMap[metricName] = append(metricRulesMap[metricName], rule)
}
}
}
}
for _, query := range rule.RuleCondition.CompositeQuery.ClickHouseQueries {
if query.Query != "" {
for _, metricName := range metricNames {
if strings.Contains(query.Query, metricName) {
metricRulesMap[metricName] = append(metricRulesMap[metricName], rule)
}
}
}
}
}
for _, metricName := range metricNames {
if rules, exists := metricRulesMap[metricName]; exists {
seen := make(map[string]bool)
uniqueRules := make([]ruletypes.GettableRule, 0)
for _, rule := range rules {
if !seen[rule.Id] {
seen[rule.Id] = true
uniqueRules = append(uniqueRules, rule)
}
}
result[metricName] = uniqueRules
}
}
return result, nil
return alertCount, err
}

View File

@@ -110,11 +110,8 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {
@@ -209,13 +206,13 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
fingerprintData := [][]any{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
validSamplesData := make([][]any, 0)
for _, v := range tc.Values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
@@ -223,7 +220,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
}
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
validSamplesData = append(validSamplesData, []any{
"test_metric",
fingerprint,
sampleTimestamp,
@@ -263,11 +260,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
},
})
count, apiErr := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
if apiErr != nil {
t.Logf("TestNotification error: %v, type: %s", apiErr.Err, apiErr.Typ)
}
require.Nil(t, apiErr)
count, err := mgr.TestNotification(context.Background(), orgID, string(ruleBytes))
require.Nil(t, err)
assert.Equal(t, tc.ExpectAlerts, count)
if tc.ExpectAlerts > 0 {

View File

@@ -4,7 +4,6 @@ import (
"math"
"time"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -15,7 +14,7 @@ import (
// ThresholdRuleTestCase defines test case structure for threshold rule test notifications
type ThresholdRuleTestCase struct {
Name string
Values [][]interface{}
Values [][]any
ExpectAlerts int
ExpectValue float64
}
@@ -52,11 +51,11 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -80,11 +79,11 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Name: "primary",
TargetValue: &target,
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
},
@@ -111,13 +110,13 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
},
Version: "v5",
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOp: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypePromQL,
PanelType: v3.PanelTypeGraph,
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
PanelType: ruletypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
@@ -134,12 +133,12 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
Name: "primary",
TargetValue: &target,
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},
@@ -153,7 +152,7 @@ var (
TcTestNotiSendUnmatchedThresholdRule = []ThresholdRuleTestCase{
{
Name: "return first valid point in case of test notification",
Values: [][]interface{}{
Values: [][]any{
{float64(3), "attr", time.Now()},
{float64(4), "attr", time.Now().Add(1 * time.Minute)},
},
@@ -162,12 +161,12 @@ var (
},
{
Name: "No data in DB so no alerts fired",
Values: [][]interface{}{},
Values: [][]any{},
ExpectAlerts: 0,
},
{
Name: "return first valid point in case of test notification skips NaN and Inf",
Values: [][]interface{}{
Values: [][]any{
{math.NaN(), "attr", time.Now()},
{math.Inf(1), "attr", time.Now().Add(1 * time.Minute)},
{float64(7), "attr", time.Now().Add(2 * time.Minute)},
@@ -177,7 +176,7 @@ var (
},
{
Name: "If found matching alert with given target value, return the alerting value rather than first valid point",
Values: [][]interface{}{
Values: [][]any{
{float64(1), "attr", time.Now()},
{float64(2), "attr", time.Now().Add(1 * time.Minute)},
{float64(3), "attr", time.Now().Add(2 * time.Minute)},

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
@@ -88,7 +87,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
}
// Create reader with mocked telemetry store
readerCache, err := cachetest.New(cache.Config{
cache, err := cachetest.New(cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 1000,
@@ -97,28 +96,16 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
})
require.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
providerSettings := instrumentationtest.New().ToProviderSettings()
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
reader := clickhouseReader.NewReader(
instrumentationtest.New().Logger(),
nil,
telemetryStore,
prometheus,
"",
time.Duration(time.Second),
nil,
readerCache,
options,
)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
if err != nil {
t.Fatalf("failed to create flagger: %v", err)
}
// Create mock querierV5 with test values
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, readerCache, flagger)
// Create querier with test values
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, cache, flagger)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)
@@ -128,8 +115,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
Alertmanager: fAlert,
Querier: mockQuerier,
TelemetryStore: telemetryStore,
Reader: reader,
SqlStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
SQLStore: sqlStore, // SQLStore needed for SendAlerts to query organizations
}
// Call the ManagerOptions hook if provided to allow customization

View File

@@ -12,14 +12,10 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qslabels "github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -37,13 +33,12 @@ func NewPromRule(
orgID valuer.UUID,
postableRule *ruletypes.PostableRule,
logger *slog.Logger,
reader interfaces.Reader,
prometheus prometheus.Prometheus,
opts ...RuleOption,
) (*PromRule, error) {
opts = append(opts, WithLogger(logger))
baseRule, err := NewBaseRule(id, orgID, postableRule, reader, opts...)
baseRule, err := NewBaseRule(id, orgID, postableRule, opts...)
if err != nil {
return nil, err
}
@@ -55,12 +50,12 @@ func NewPromRule(
}
p.logger = logger
query, err := p.getPqlQuery()
query, err := p.getPqlQuery(context.Background())
if err != nil {
// can not generate a valid prom QL query
return nil, err
}
logger.Info("creating new prom rule", "rule_name", p.name, "query", query)
logger.Info("creating new prom rule", slog.String("rule.id", id), slog.String("rule.query", query))
return &p, nil
}
@@ -68,76 +63,41 @@ func (r *PromRule) Type() ruletypes.RuleType {
return ruletypes.RuleTypeProm
}
func (r *PromRule) GetSelectedQuery() string {
if r.ruleCondition != nil {
// If the user has explicitly set the selected query, we return that.
if r.ruleCondition.SelectedQuery != "" {
return r.ruleCondition.SelectedQuery
}
// Historically, we used to have only one query in the alerts for promql.
// So, if there is only one query, we return that.
// This is to maintain backward compatibility.
// For new rules, we will have to explicitly set the selected query.
return "A"
}
// This should never happen.
return ""
}
func (r *PromRule) getPqlQuery() (string, error) {
if r.version == "v5" {
if len(r.ruleCondition.CompositeQuery.Queries) > 0 {
selectedQuery := r.GetSelectedQuery()
for _, item := range r.ruleCondition.CompositeQuery.Queries {
switch item.Type {
case qbtypes.QueryTypePromQL:
promQuery, ok := item.Spec.(qbtypes.PromQuery)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", item.Spec)
}
if promQuery.Name == selectedQuery {
return promQuery.Query, nil
}
}
func (r *PromRule) getPqlQuery(ctx context.Context) (string, error) {
selectedQuery := r.SelectedQuery(ctx)
for _, item := range r.ruleCondition.CompositeQuery.Queries {
switch item.Type {
case qbtypes.QueryTypePromQL:
promQuery, ok := item.Spec.(qbtypes.PromQuery)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", item.Spec)
}
}
return "", fmt.Errorf("invalid promql rule setup")
}
if r.ruleCondition.CompositeQuery.QueryType == v3.QueryTypePromQL {
if len(r.ruleCondition.CompositeQuery.PromQueries) > 0 {
selectedQuery := r.GetSelectedQuery()
if promQuery, ok := r.ruleCondition.CompositeQuery.PromQueries[selectedQuery]; ok {
query := promQuery.Query
if query == "" {
return query, fmt.Errorf("a promquery needs to be set for this rule to function")
}
return query, nil
if promQuery.Name == selectedQuery {
return promQuery.Query, nil
}
}
}
return "", fmt.Errorf("invalid promql rule query")
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql rule setup")
}
func (r *PromRule) matrixToV3Series(res promql.Matrix) []*v3.Series {
v3Series := make([]*v3.Series, 0, len(res))
func (r *PromRule) matrixToCommonSeries(res promql.Matrix) []*qbtypes.TimeSeries {
seriesSlice := make([]*qbtypes.TimeSeries, 0, len(res))
for _, series := range res {
commonSeries := toCommonSeries(series)
v3Series = append(v3Series, &commonSeries)
seriesSlice = append(seriesSlice, commonSeries)
}
return v3Series
return seriesSlice
}
func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletypes.Vector, error) {
start, end := r.Timestamps(ts)
interval := 60 * time.Second // TODO(srikanthccv): this should be configurable
q, err := r.getPqlQuery()
q, err := r.getPqlQuery(ctx)
if err != nil {
return nil, err
}
r.logger.InfoContext(ctx, "evaluating promql query", "rule_name", r.Name(), "query", q)
r.logger.InfoContext(ctx, "evaluating promql query", slog.String("rule.id", r.ID()), slog.String("rule.query", q))
res, err := r.RunAlertQuery(ctx, q, start, end, interval)
if err != nil {
r.SetHealth(ruletypes.HealthBad)
@@ -145,7 +105,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
return nil, err
}
matrixToProcess := r.matrixToV3Series(res)
matrixToProcess := r.matrixToCommonSeries(res)
hasData := len(matrixToProcess) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
@@ -157,7 +117,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, matrixToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
} else {
matrixToProcess = filteredSeries
}
@@ -169,11 +129,11 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(
ctx, "not enough data points to evaluate series, skipping",
"rule_id", r.ID(), "num_points", len(series.Points), "required_points", r.Condition().RequiredNumPoints,
"rule.id", r.ID(), "num_points", len(series.Values), "required_points", r.Condition().RequiredNumPoints,
)
continue
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
resultSeries, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -213,7 +173,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for _, lbl := range result.Metric {
l[lbl.Name] = lbl.Value
}
r.logger.DebugContext(ctx, "alerting for series", "rule_name", r.Name(), "series", result)
r.logger.DebugContext(ctx, "alerting for series", slog.String("rule.id", r.ID()), slog.Any("series", result))
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
@@ -228,35 +188,34 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.WarnContext(ctx, "Expanding alert template failed", "rule_name", r.Name(), errors.Attr(err), "data", tmplData)
r.logger.WarnContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
lb := qslabels.NewBuilder(result.Metric).Del(qslabels.MetricNameLabel)
resultLabels := qslabels.NewBuilder(result.Metric).Del(qslabels.MetricNameLabel).Labels()
lb := ruletypes.NewBuilder(result.Metric...).Del(ruletypes.MetricNameLabel)
resultLabels := ruletypes.NewBuilder(result.Metric...).Del(ruletypes.MetricNameLabel).Labels()
for name, value := range r.labels.Map() {
lb.Set(name, expand(value))
}
lb.Set(qslabels.AlertNameLabel, r.Name())
lb.Set(qslabels.AlertRuleIdLabel, r.ID())
lb.Set(qslabels.RuleSourceLabel, r.GeneratorURL())
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(qslabels.Labels, 0, len(r.annotations.Map()))
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
annotations = append(annotations, qslabels.Label{Name: name, Value: expand(value)})
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if result.IsMissing {
lb.Set(qslabels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(qslabels.NoDataLabel, "true")
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
}
lbs := lb.Labels()
@@ -264,7 +223,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
err = fmt.Errorf("vector contains metrics with the same labelset after applying alert labels")
err = errors.NewInternalf(errors.CodeInternal, "vector contains metrics with the same labelset after applying alert labels")
// We have already acquired the lock above hence using SetHealth and
// SetLastError will deadlock.
r.health = ruletypes.HealthBad
@@ -273,10 +232,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
QueryResultLabels: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: model.StatePending,
State: ruletypes.StatePending,
Value: result.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -285,12 +244,12 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
// Update the recovering and missing state of existing alert
@@ -306,75 +265,75 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
itemsToAdd := []model.RuleStateHistory{}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLables)
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != model.StateInactive {
a.State = model.StateInactive
if a.State != ruletypes.StateInactive {
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: model.StateInactive,
State: ruletypes.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
})
}
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
a.State = model.StateFiring
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := model.StateFiring
state := ruletypes.StateFiring
if a.Missing {
state = model.StateNoData
state = ruletypes.StateNoData
}
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeAlertingToRecovering := a.State == model.StateFiring && a.IsRecovering
changeAlertingToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeAlertingToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
state := ruletypes.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
@@ -452,26 +411,25 @@ func (r *PromRule) RunAlertQuery(ctx context.Context, qs string, start, end time
case promql.Matrix:
return res.Value.(promql.Matrix), nil
default:
return nil, fmt.Errorf("rule result is not a vector or scalar")
return nil, errors.NewInternalf(errors.CodeInternal, "rule result is not a vector or scalar")
}
}
func toCommonSeries(series promql.Series) v3.Series {
commonSeries := v3.Series{
Labels: make(map[string]string),
LabelsArray: make([]map[string]string, 0),
Points: make([]v3.Point, 0),
func toCommonSeries(series promql.Series) *qbtypes.TimeSeries {
commonSeries := &qbtypes.TimeSeries{
Labels: make([]*qbtypes.Label, 0),
Values: make([]*qbtypes.TimeSeriesValue, 0),
}
series.Metric.Range(func(lbl labels.Label) {
commonSeries.Labels[lbl.Name] = lbl.Value
commonSeries.LabelsArray = append(commonSeries.LabelsArray, map[string]string{
lbl.Name: lbl.Value,
commonSeries.Labels = append(commonSeries.Labels, &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: lbl.Name},
Value: lbl.Value,
})
})
for _, f := range series.Floats {
commonSeries.Points = append(commonSeries.Points, v3.Point{
commonSeries.Values = append(commonSeries.Values, &qbtypes.TimeSeriesValue{
Timestamp: f.T,
Value: f.F,
})

View File

@@ -2,7 +2,6 @@ package rules
import (
"context"
"fmt"
"sort"
"sync"
"time"
@@ -187,7 +186,7 @@ func (g *PromRuleTask) PromRules() []*PromRule {
}
}
sort.Slice(alerts, func(i, j int) bool {
return alerts[i].State() > alerts[j].State() ||
return alerts[i].State().Severity() > alerts[j].State().Severity() ||
(alerts[i].State() == alerts[j].State() &&
alerts[i].Name() < alerts[j].Name())
})
@@ -268,7 +267,7 @@ func (g *PromRuleTask) CopyState(fromTask Task) error {
from, ok := fromTask.(*PromRuleTask)
if !ok {
return fmt.Errorf("you can only copy rule groups with same type")
return errors.NewInternalf(errors.CodeInternal, "you can only copy rule groups with same type")
}
g.evaluationTime = from.evaluationTime
@@ -343,7 +342,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", "rule", rule.ID(), "maintenance", m)
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
@@ -351,7 +350,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", "rule", rule.ID())
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
@@ -383,7 +382,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
rule.SetHealth(ruletypes.HealthBad)
rule.SetLastError(err)
g.logger.WarnContext(ctx, "evaluating rule failed", "rule_id", rule.ID(), errors.Attr(err))
g.logger.WarnContext(ctx, "evaluating rule failed", slog.String("rule.id", rule.ID()), errors.Attr(err))
// Canceled queries are intentional termination of queries. This normally
// happens on shutdown and thus we skip logging of any errors here.

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
package rules

View File

@@ -4,8 +4,7 @@ import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -17,13 +16,13 @@ type Rule interface {
Name() string
Type() ruletypes.RuleType
Labels() labels.BaseLabels
Annotations() labels.BaseLabels
Labels() ruletypes.Labels
Annotations() ruletypes.Labels
Condition() *ruletypes.RuleCondition
EvalDelay() valuer.TextDuration
EvalWindow() valuer.TextDuration
HoldDuration() valuer.TextDuration
State() model.AlertState
State() ruletypes.AlertState
ActiveAlerts() []*ruletypes.Alert
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
ActiveAlertsLabelFP() map[uint64]struct{}
@@ -42,7 +41,17 @@ type Rule interface {
SetEvaluationTimestamp(time.Time)
GetEvaluationTimestamp() time.Time
RecordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []model.RuleStateHistory) error
RecordRuleStateHistory(
ctx context.Context,
prevState, currentState ruletypes.AlertState,
itemsToAdd []rulestatehistorytypes.RuleStateHistory,
) error
SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc)
SendAlerts(
ctx context.Context,
ts time.Time,
resendDelay time.Duration,
interval time.Duration,
notifyFunc NotifyFunc,
)
}

View File

@@ -2,7 +2,6 @@ package rules
import (
"context"
"fmt"
"sort"
"sync"
"time"
@@ -12,10 +11,9 @@ import (
opentracing "github.com/opentracing/opentracing-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -95,7 +93,7 @@ func (g *RuleTask) Pause(b bool) {
type QueryOrigin struct{}
func NewQueryOriginContext(ctx context.Context, data map[string]interface{}) context.Context {
func NewQueryOriginContext(ctx context.Context, data map[string]any) context.Context {
return context.WithValue(ctx, QueryOrigin{}, data)
}
@@ -111,7 +109,7 @@ func (g *RuleTask) Run(ctx context.Context) {
return
}
ctx = NewQueryOriginContext(ctx, map[string]interface{}{
ctx = NewQueryOriginContext(ctx, map[string]any{
"ruleRuleTask": map[string]string{
"name": g.Name(),
},
@@ -163,8 +161,8 @@ func (g *RuleTask) Stop() {
}
func (g *RuleTask) hash() uint64 {
l := labels.New(
labels.Label{Name: "name", Value: g.name},
l := ruletypes.New(
ruletypes.Label{Name: "name", Value: g.name},
)
return l.Hash()
}
@@ -180,7 +178,7 @@ func (g *RuleTask) ThresholdRules() []*ThresholdRule {
}
}
sort.Slice(alerts, func(i, j int) bool {
return alerts[i].State() > alerts[j].State() ||
return alerts[i].State().Severity() > alerts[j].State().Severity() ||
(alerts[i].State() == alerts[j].State() &&
alerts[i].Name() < alerts[j].Name())
})
@@ -265,7 +263,7 @@ func (g *RuleTask) CopyState(fromTask Task) error {
from, ok := fromTask.(*RuleTask)
if !ok {
return fmt.Errorf("invalid from task for copy")
return errors.NewInternalf(errors.CodeInternal, "invalid from task for copy")
}
g.evaluationTime = from.evaluationTime
g.lastEvaluation = from.lastEvaluation
@@ -329,7 +327,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", "rule", rule.ID(), "maintenance", m)
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
@@ -337,7 +335,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", "rule", rule.ID())
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
@@ -369,7 +367,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
rule.SetHealth(ruletypes.HealthBad)
rule.SetLastError(err)
g.logger.WarnContext(ctx, "evaluating rule failed", "rule_id", rule.ID(), errors.Attr(err))
g.logger.WarnContext(ctx, "evaluating rule failed", slog.String("rule.id", rule.ID()), errors.Attr(err))
// Canceled queries are intentional termination of queries. This normally
// happens on shutdown and thus we skip logging of any errors here.

View File

@@ -0,0 +1,162 @@
package rules
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/querybuilder/resourcefilter"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/require"
)
func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.TelemetryStore) querier.Querier {
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
flagger, err := flagger.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
flagger.Config{},
flagger.MustNewRegistry(),
)
require.NoError(t, err)
metricFieldMapper := telemetrymetrics.NewFieldMapper()
metricConditionBuilder := telemetrymetrics.NewConditionBuilder(metricFieldMapper)
metricStmtBuilder := telemetrymetrics.NewMetricQueryStatementBuilder(
providerSettings,
metadataStore,
metricFieldMapper,
metricConditionBuilder,
flagger,
)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
metricStmtBuilder,
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
}
func prepareQuerierForLogs(telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalLogs
}
}
metadataStore.KeysMap = keysMap
resourceFilterFieldMapper := resourcefilter.NewFieldMapper()
resourceFilterConditionBuilder := resourcefilter.NewConditionBuilder(resourceFilterFieldMapper)
logFieldMapper := telemetrylogs.NewFieldMapper()
logConditionBuilder := telemetrylogs.NewConditionBuilder(logFieldMapper)
logResourceFilterStmtBuilder := resourcefilter.NewLogResourceFilterStatementBuilder(
providerSettings,
resourceFilterFieldMapper,
resourceFilterConditionBuilder,
metadataStore,
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
)
logAggExprRewriter := querybuilder.NewAggExprRewriter(
providerSettings,
telemetrylogs.DefaultFullTextColumn,
logFieldMapper,
logConditionBuilder,
telemetrylogs.GetBodyJSONKey,
)
logStmtBuilder := telemetrylogs.NewLogQueryStatementBuilder(
providerSettings,
metadataStore,
logFieldMapper,
logConditionBuilder,
logResourceFilterStmtBuilder,
logAggExprRewriter,
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
logStmtBuilder, // logStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
}
func prepareQuerierForTraces(telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalTraces
}
}
metadataStore.KeysMap = keysMap
// Create trace statement builder
traceFieldMapper := telemetrytraces.NewFieldMapper()
traceConditionBuilder := telemetrytraces.NewConditionBuilder(traceFieldMapper)
resourceFilterFieldMapper := resourcefilter.NewFieldMapper()
resourceFilterConditionBuilder := resourcefilter.NewConditionBuilder(resourceFilterFieldMapper)
resourceFilterStmtBuilder := resourcefilter.NewTraceResourceFilterStatementBuilder(
providerSettings,
resourceFilterFieldMapper,
resourceFilterConditionBuilder,
metadataStore,
)
traceAggExprRewriter := querybuilder.NewAggExprRewriter(providerSettings, nil, traceFieldMapper, traceConditionBuilder, nil)
traceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
providerSettings,
metadataStore,
traceFieldMapper,
traceConditionBuilder,
resourceFilterStmtBuilder,
traceAggExprRewriter,
telemetryStore,
)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
traceStmtBuilder, // traceStmtBuilder
nil, // logStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"time"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)

View File

@@ -10,18 +10,16 @@ import (
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// TestNotification prepares a dummy rule for given rule parameters and
// sends a test notification. returns alert count and error (if any)
func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError) {
func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
ctx := context.Background()
if opts.Rule == nil {
return 0, model.BadRequest(fmt.Errorf("rule is required"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule is required")
}
parsedRule := opts.Rule
@@ -41,15 +39,14 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
parsedRule.Labels[ruletypes.RuleSourceLabel] = ""
parsedRule.Labels[ruletypes.AlertRuleIDLabel] = ""
// create a threshold rule
rule, err = NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Querier,
opts.Logger,
WithSendAlways(),
@@ -61,7 +58,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
if err != nil {
slog.Error("failed to prepare a new threshold rule for test", errors.Attr(err))
return 0, model.BadRequest(err)
return 0, err
}
} else if parsedRule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +69,6 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
opts.ManagerOpts.Prometheus,
WithSendAlways(),
WithSendUnmatched(),
@@ -83,10 +79,10 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
if err != nil {
slog.Error("failed to prepare a new promql rule for test", errors.Attr(err))
return 0, model.BadRequest(err)
return 0, err
}
} else {
return 0, model.BadRequest(fmt.Errorf("failed to derive ruletype with given information"))
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule type")
}
// set timestamp to current utc time
@@ -94,8 +90,8 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
alertsFound, err := rule.Eval(ctx, ts)
if err != nil {
slog.Error("evaluating rule failed", "rule", rule.Name(), errors.Attr(err))
return 0, model.InternalError(fmt.Errorf("rule evaluation failed"))
slog.Error("evaluating rule failed", slog.String("rule.id", rule.ID()), errors.Attr(err))
return 0, err
}
rule.SendAlerts(ctx, ts, 0, time.Duration(1*time.Minute), opts.NotifyFunc)

View File

@@ -1,67 +1,33 @@
package rules
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/url"
"reflect"
"text/template"
"time"
"github.com/SigNoz/signoz/pkg/contextlinks"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/query-service/app/querier"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
querytemplate "github.com/SigNoz/signoz/pkg/query-service/utils/queryTemplate"
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
logsv3 "github.com/SigNoz/signoz/pkg/query-service/app/logs/v3"
tracesV4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
"github.com/SigNoz/signoz/pkg/units"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type ThresholdRule struct {
*BaseRule
// Ever since we introduced the new metrics query builder, the version is "v4"
// for all the rules
// if the version is "v3", then we use the old querier
// if the version is "v4", then we use the new querierV2
version string
// querier is used for alerts created before the introduction of new metrics query builder
querier interfaces.Querier
// querierV2 is used for alerts created after the introduction of new metrics query builder
querierV2 interfaces.Querier
// querierV5 is used for alerts migrated after the introduction of new query builder
querierV5 querierV5.Querier
// used for attribute metadata enrichment for logs and traces
logsKeys map[string]v3.AttributeKey
spansKeys map[string]v3.AttributeKey
querier querier.Querier
}
var _ Rule = (*ThresholdRule)(nil)
@@ -70,8 +36,7 @@ func NewThresholdRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
reader interfaces.Reader,
querierV5 querierV5.Querier,
querier querier.Querier,
logger *slog.Logger,
opts ...RuleOption,
) (*ThresholdRule, error) {
@@ -79,213 +44,39 @@ func NewThresholdRule(
opts = append(opts, WithLogger(logger))
baseRule, err := NewBaseRule(id, orgID, p, reader, opts...)
baseRule, err := NewBaseRule(id, orgID, p, opts...)
if err != nil {
return nil, err
}
t := ThresholdRule{
return &ThresholdRule{
BaseRule: baseRule,
version: p.Version,
}
querierOption := querier.QuerierOptions{
Reader: reader,
Cache: nil,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: nil,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querier = querier.NewQuerier(querierOption)
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
t.querierV5 = querierV5
t.reader = reader
return &t, nil
querier: querier,
}, nil
}
func (r *ThresholdRule) hostFromSource() string {
parsedUrl, err := url.Parse(r.source)
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())
if parsedURL.Port() != "" {
return fmt.Sprintf("%s://%s:%s", parsedURL.Scheme, parsedURL.Hostname(), parsedURL.Port())
}
return fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Hostname())
return fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Hostname())
}
func (r *ThresholdRule) Type() ruletypes.RuleType {
return ruletypes.RuleTypeThreshold
}
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.evalWindow.Milliseconds(), "eval_delay", r.evalDelay.Milliseconds(),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
if r.ruleCondition.QueryType() == v3.QueryTypeClickHouseSQL {
params := &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: &v3.CompositeQuery{
QueryType: r.ruleCondition.CompositeQuery.QueryType,
PanelType: r.ruleCondition.CompositeQuery.PanelType,
BuilderQueries: make(map[string]*v3.BuilderQuery),
ClickHouseQueries: make(map[string]*v3.ClickHouseQuery),
PromQueries: make(map[string]*v3.PromQuery),
Unit: r.ruleCondition.CompositeQuery.Unit,
},
Variables: make(map[string]interface{}),
NoCache: true,
}
querytemplate.AssignReservedVarsV3(params)
for name, chQuery := range r.ruleCondition.CompositeQuery.ClickHouseQueries {
if chQuery.Disabled {
continue
}
tmpl := template.New("clickhouse-query")
tmpl, err := tmpl.Parse(chQuery.Query)
if err != nil {
return nil, err
}
var query bytes.Buffer
err = tmpl.Execute(&query, params.Variables)
if err != nil {
return nil, err
}
params.CompositeQuery.ClickHouseQueries[name] = &v3.ClickHouseQuery{
Query: query.String(),
Disabled: chQuery.Disabled,
Legend: chQuery.Legend,
}
}
return params, nil
}
if r.ruleCondition.CompositeQuery != nil && r.ruleCondition.CompositeQuery.BuilderQueries != nil {
for _, q := range r.ruleCondition.CompositeQuery.BuilderQueries {
// If the step interval is less than the minimum allowed step interval, set it to the minimum allowed step interval
if minStep := common.MinAllowedStepInterval(start, end); q.StepInterval < minStep {
q.StepInterval = minStep
}
q.SetShiftByFromFunc()
if q.DataSource == v3.DataSourceMetrics {
// if the time range is greater than 1 day, and less than 1 week set the step interval to be multiple of 5 minutes
// if the time range is greater than 1 week, set the step interval to be multiple of 30 mins
if end-start >= 24*time.Hour.Milliseconds() && end-start < 7*24*time.Hour.Milliseconds() {
q.StepInterval = int64(math.Round(float64(q.StepInterval)/300)) * 300
} else if end-start >= 7*24*time.Hour.Milliseconds() {
q.StepInterval = int64(math.Round(float64(q.StepInterval)/1800)) * 1800
}
}
}
}
if r.ruleCondition.CompositeQuery.PanelType != v3.PanelTypeGraph {
r.ruleCondition.CompositeQuery.PanelType = v3.PanelTypeGraph
}
// default mode
return &v3.QueryRangeParamsV3{
Start: start,
End: end,
Step: int64(math.Max(float64(common.MinAllowedStepInterval(start, end)), 60)),
CompositeQuery: r.ruleCondition.CompositeQuery,
Variables: make(map[string]interface{}),
NoCache: true,
}, nil
}
func (r *ThresholdRule) prepareLinksToLogs(ctx context.Context, ts time.Time, lbls labels.Labels) string {
if r.version == "v5" {
return r.prepareLinksToLogsV5(ctx, ts, lbls)
}
selectedQuery := r.GetSelectedQuery()
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
}
start := time.UnixMilli(qr.Start)
end := time.UnixMilli(qr.End)
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
return ""
}
q := r.ruleCondition.CompositeQuery.BuilderQueries[selectedQuery]
if q == nil {
return ""
}
if q.DataSource != v3.DataSourceLogs {
return ""
}
queryFilter := []v3.FilterItem{}
if q.Filters != nil {
queryFilter = q.Filters.Items
}
filterItems := contextlinks.PrepareFilters(lbls.Map(), queryFilter, q.GroupBy, r.logsKeys)
return contextlinks.PrepareLinksToLogs(start, end, filterItems)
}
func (r *ThresholdRule) prepareLinksToTraces(ctx context.Context, ts time.Time, lbls labels.Labels) string {
if r.version == "v5" {
return r.prepareLinksToTracesV5(ctx, ts, lbls)
}
selectedQuery := r.GetSelectedQuery()
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
}
start := time.UnixMilli(qr.Start)
end := time.UnixMilli(qr.End)
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
return ""
}
q := r.ruleCondition.CompositeQuery.BuilderQueries[selectedQuery]
if q == nil {
return ""
}
if q.DataSource != v3.DataSourceTraces {
return ""
}
queryFilter := []v3.FilterItem{}
if q.Filters != nil {
queryFilter = q.Filters.Items
}
filterItems := contextlinks.PrepareFilters(lbls.Map(), queryFilter, q.GroupBy, r.spansKeys)
return contextlinks.PrepareLinksToTraces(start, end, filterItems)
}
func (r *ThresholdRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
r.logger.InfoContext(
ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.evalWindow.Milliseconds(), "eval_delay", r.evalDelay.Milliseconds(),
ctx, "prepare query range request v5",
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
slog.String("rule.id", r.ID()),
)
startTs, endTs := r.Timestamps(ts)
@@ -305,10 +96,10 @@ func (r *ThresholdRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (
return req, nil
}
func (r *ThresholdRule) prepareLinksToLogsV5(ctx context.Context, ts time.Time, lbls labels.Labels) string {
selectedQuery := r.GetSelectedQuery()
func (r *ThresholdRule) prepareLinksToLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) string {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRangeV5(ctx, ts)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
}
@@ -345,10 +136,10 @@ func (r *ThresholdRule) prepareLinksToLogsV5(ctx context.Context, ts time.Time,
return contextlinks.PrepareLinksToLogsV5(start, end, whereClause)
}
func (r *ThresholdRule) prepareLinksToTracesV5(ctx context.Context, ts time.Time, lbls labels.Labels) string {
selectedQuery := r.GetSelectedQuery()
func (r *ThresholdRule) prepareLinksToTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) string {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRangeV5(ctx, ts)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return ""
}
@@ -385,151 +176,36 @@ func (r *ThresholdRule) prepareLinksToTracesV5(ctx context.Context, ts time.Time
return contextlinks.PrepareLinksToTracesV5(start, end, whereClause)
}
func (r *ThresholdRule) GetSelectedQuery() string {
return r.ruleCondition.GetSelectedQueryName()
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
if params.CompositeQuery.QueryType == v3.QueryTypeBuilder {
hasLogsQuery := false
hasTracesQuery := false
for _, query := range params.CompositeQuery.BuilderQueries {
if query.DataSource == v3.DataSourceLogs {
hasLogsQuery = true
}
if query.DataSource == v3.DataSourceTraces {
hasTracesQuery = true
}
}
var results []*qbtypes.TimeSeriesData
if hasLogsQuery {
// check if any enrichment is required for logs if yes then enrich them
if logsv3.EnrichmentRequired(params) {
logsFields, apiErr := r.reader.GetLogFieldsFromNames(ctx, logsv3.GetFieldNames(params.CompositeQuery))
if apiErr != nil {
return nil, apiErr.ToError()
}
logsKeys := model.GetLogFieldsV3(ctx, params, logsFields)
r.logsKeys = logsKeys
logsv3.Enrich(params, logsKeys)
}
}
if hasTracesQuery {
spanKeys, err := r.reader.GetSpanAttributeKeysByNames(ctx, logsv3.GetFieldNames(params.CompositeQuery))
if err != nil {
return nil, err
}
r.spansKeys = spanKeys
tracesV4.Enrich(params, spanKeys)
}
}
var results []*v3.Result
var queryErrors map[string]error
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "rules",
instrumentationtypes.CodeFunctionName: "buildAndRunQuery",
})
if r.version == "v4" {
results, queryErrors, err = r.querierV2.QueryRange(ctx, orgID, params)
} else {
results, queryErrors, err = r.querier.QueryRange(ctx, orgID, params)
}
if err != nil {
r.logger.ErrorContext(ctx, "failed to get alert query range result", "rule_name", r.Name(), errors.Attr(err), "query_errors", queryErrors)
return nil, fmt.Errorf("internal error while querying")
}
if params.CompositeQuery.QueryType == v3.QueryTypeBuilder {
results, err = postprocess.PostProcessResult(results, params)
if err != nil {
r.logger.ErrorContext(ctx, "failed to post process result", "rule_name", r.Name(), errors.Attr(err))
return nil, fmt.Errorf("internal error while post processing")
}
}
selectedQuery := r.GetSelectedQuery()
var queryResult *v3.Result
for _, res := range results {
if res.QueryName == selectedQuery {
queryResult = res
break
}
}
hasData := queryResult != nil && len(queryResult.Series) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
if queryResult == nil {
r.logger.WarnContext(ctx, "query result is nil", "rule_name", r.Name(), "query_name", selectedQuery)
return resultVector, nil
}
for _, series := range queryResult.Series {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
}
resultVector = append(resultVector, resultSeries...)
}
return resultVector, nil
}
func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRangeV5(ctx, ts)
v5Result, err := r.querier.QueryRange(ctx, orgID, params)
if err != nil {
return nil, err
}
var results []*v3.Result
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "rules",
instrumentationtypes.CodeFunctionName: "buildAndRunQueryV5",
})
v5Result, err := r.querierV5.QueryRange(ctx, orgID, params)
if err != nil {
r.logger.ErrorContext(ctx, "failed to get alert query result", "rule_name", r.Name(), errors.Attr(err))
return nil, fmt.Errorf("internal error while querying")
}
for _, item := range v5Result.Data.Results {
if tsData, ok := item.(*qbtypes.TimeSeriesData); ok {
results = append(results, transition.ConvertV5TimeSeriesDataToV4Result(tsData))
results = append(results, tsData)
} else {
// NOTE: should not happen but just to ensure we don't miss it if it happens for some reason
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got", "item_type", reflect.TypeOf(item))
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got unexpected type", slog.String("rule.id", r.ID()), slog.String("item.type", reflect.TypeOf(item).String()))
}
}
selectedQuery := r.GetSelectedQuery()
selectedQuery := r.SelectedQuery(ctx)
var queryResult *v3.Result
var queryResult *qbtypes.TimeSeriesData
for _, res := range results {
if res.QueryName == selectedQuery {
queryResult = res
@@ -537,25 +213,29 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
}
}
hasData := queryResult != nil && len(queryResult.Series) > 0
hasData := queryResult != nil &&
len(queryResult.Aggregations) > 0 &&
queryResult.Aggregations[0] != nil &&
len(queryResult.Aggregations[0].Series) > 0
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
}
var resultVector ruletypes.Vector
if queryResult == nil {
r.logger.WarnContext(ctx, "query result is nil", "rule_name", r.Name(), "query_name", selectedQuery)
if queryResult == nil || len(queryResult.Aggregations) == 0 || queryResult.Aggregations[0] == nil {
r.logger.WarnContext(ctx, "query result is nil", slog.String("rule.id", r.ID()), slog.String("query.name", selectedQuery))
return resultVector, nil
}
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.Series
seriesToProcess := queryResult.Aggregations[0].Series
if r.ShouldSkipNewGroups() {
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "Error filtering new series, ", errors.Attr(filterErr), "rule_name", r.Name())
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -563,10 +243,10 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
continue
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
resultSeries, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -587,13 +267,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
if r.version == "v5" {
r.logger.InfoContext(ctx, "running v5 query")
res, err = r.buildAndRunQueryV5(ctx, r.orgID, ts)
} else {
r.logger.InfoContext(ctx, "running v4 query")
res, err = r.buildAndRunQuery(ctx, r.orgID, ts)
}
res, err = r.buildAndRunQuery(ctx, r.orgID, ts)
if err != nil {
return 0, err
@@ -620,7 +294,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
value := valueFormatter.Format(smpl.V, r.Unit())
// todo(aniket): handle different threshold
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -634,35 +308,34 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
defs+text,
"__alert_"+r.Name(),
tmplData,
times.Time(timestamp.FromTime(ts)),
nil,
)
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "Expanding alert template failed", errors.Attr(err), "data", tmplData)
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel)
resultLabels := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels()
lb := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel)
resultLabels := ruletypes.NewBuilder(smpl.Metric...).Del(ruletypes.MetricNameLabel).Del(ruletypes.TemporalityLabel).Labels()
for name, value := range r.labels.Map() {
lb.Set(name, expand(value))
}
lb.Set(labels.AlertNameLabel, r.Name())
lb.Set(labels.AlertRuleIdLabel, r.ID())
lb.Set(labels.RuleSourceLabel, r.GeneratorURL())
lb.Set(ruletypes.AlertNameLabel, r.Name())
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := make(labels.Labels, 0, len(r.annotations.Map()))
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
annotations = append(annotations, labels.Label{Name: name, Value: expand(value)})
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
}
// Links with timestamps should go in annotations since labels
@@ -672,14 +345,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
case ruletypes.AlertTypeTraces:
link := r.prepareLinksToTraces(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding traces link to annotations", "link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link))
annotations = append(annotations, labels.Label{Name: "related_traces", Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: "related_traces", Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
}
case ruletypes.AlertTypeLogs:
link := r.prepareLinksToLogs(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding logs link to annotations", "link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link))
annotations = append(annotations, labels.Label{Name: "related_logs", Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: "related_logs", Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
}
}
@@ -688,15 +361,15 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
return 0, fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
QueryResultLabels: resultLabels,
Annotations: annotations,
ActiveAt: ts,
State: model.StatePending,
State: ruletypes.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
@@ -705,13 +378,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
if alert, ok := r.Active[h]; ok && alert.State != ruletypes.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
@@ -727,78 +400,78 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.Active[h] = a
}
itemsToAdd := []model.RuleStateHistory{}
itemsToAdd := []rulestatehistorytypes.RuleStateHistory{}
// Check if any pending alerts should be removed or fire now. Write out alert timeseries.
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLables)
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), "labels", a.Labels)
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
// retention time so it is reported as resolved to the AlertManager.
if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
if a.State == ruletypes.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > ruletypes.ResolvedRetention) {
delete(r.Active, fp)
}
if a.State != model.StateInactive {
r.logger.DebugContext(ctx, "converting firing alert to inActive", "name", r.Name())
a.State = model.StateInactive
if a.State != ruletypes.StateInactive {
r.logger.DebugContext(ctx, "converting firing alert to inactive", slog.String("rule.id", r.ID()))
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: model.StateInactive,
State: ruletypes.StateInactive,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
r.logger.DebugContext(ctx, "converting pending alert to firing", "name", r.Name())
a.State = model.StateFiring
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
r.logger.DebugContext(ctx, "converting pending alert to firing", slog.String("rule.id", r.ID()))
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := model.StateFiring
state := ruletypes.StateFiring
if a.Missing {
state = model.StateNoData
state = ruletypes.StateNoData
}
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeAlertingToRecovering := a.State == model.StateFiring && a.IsRecovering
changeAlertingToRecovering := a.State == ruletypes.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
changeRecoveringToFiring := a.State == ruletypes.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeAlertingToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
state := ruletypes.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Labels: rulestatehistorytypes.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLabels.Hash(),
Value: a.Value,
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
package times
import (
"math"
"time"
)
const (
// MinimumTick is the minimum supported time resolution. This has to be
// at least time.Second in order for the code below to work.
minimumTick = time.Millisecond
// second is the Time duration equivalent to one second.
second = int64(time.Second / minimumTick)
// The number of nanoseconds per minimum tick.
nanosPerTick = int64(minimumTick / time.Nanosecond)
// Earliest is the earliest Time representable. Handy for
// initializing a high watermark.
Earliest = Time(math.MinInt64)
// Latest is the latest Time representable. Handy for initializing
// a low watermark.
Latest = Time(math.MaxInt64)
)
type Time int64
// TimeFromUnixNano returns the Time equivalent to the Unix Time
// t provided in nanoseconds.
func TimeFromUnixNano(t int64) Time {
return Time(t / nanosPerTick)
}
func (t Time) Time() time.Time {
return time.Unix(int64(t)/second, (int64(t)%second)*nanosPerTick)
}

View File

@@ -36,6 +36,7 @@ var friendly = map[string]string{
// literals / identifiers
"NUMBER": "number",
"STRING": "string",
"BOOL": "boolean",
"QUOTED_TEXT": "quoted text",
"KEY": "field name (ex: service.name)",
@@ -88,6 +89,8 @@ func (e *SyntaxErr) Error() string {
exp := ""
if len(e.Expected) > 0 {
exp = "expecting one of {" + strings.Join(e.Expected, ", ") + "}" + " but got " + e.TokenTxt
} else if e.Msg != "" {
exp = e.Msg
}
return fmt.Sprintf("line %d:%d %s", e.Line, e.Col, exp)
}
@@ -154,9 +157,48 @@ func (l *ErrorListener) SyntaxError(
}
}
// Check which "closing" tokens are in the expected set so we can suppress
// context-inappropriate tokens from the displayed set:
// - When RPAREN is expected, hide LBRACK/RBRACK (IN-list brackets confuse
// unclosed-paren errors, e.g. "{), [}" → "{)}")
// - When RBRACK is expected, hide COMMA (inside an IN bracket list the only
// meaningful fix is to close the bracket, e.g. "{,, ]}" → "{]}")
rparenType := pGetTokenType(p, "RPAREN")
lbrackType := pGetTokenType(p, "LBRACK")
rbrackType := pGetTokenType(p, "RBRACK")
commaType := pGetTokenType(p, "COMMA")
hasRParen, hasRBrack := false, false
for _, iv := range set.GetIntervals() {
for t := iv.Start; t <= iv.Stop; t++ {
if t == rparenType {
hasRParen = true
}
if t == rbrackType {
hasRBrack = true
}
}
}
uniq := map[string]struct{}{}
for _, iv := range set.GetIntervals() {
for t := iv.Start; t <= iv.Stop; t++ {
// Exclude the offending token itself from the expected set.
// ANTLR's error recovery sometimes leaves the offending token in
// the follow-set, producing a contradictory message like
// "expecting NOT but got 'NOT'".
if t == err.TokenType {
continue
}
// When RPAREN is expected, suppress LBRACK/RBRACK — they only
// appear in IN-list contexts and confuse unclosed-paren errors.
if hasRParen && (t == lbrackType || t == rbrackType) {
continue
}
// When RBRACK is expected (inside an IN bracket list), suppress
// COMMA — the only useful fix is closing the bracket.
if hasRBrack && t == commaType {
continue
}
sym := tokenName(p, t)
if sym == "KEY" {
if !hasValueLiterals {

View File

@@ -0,0 +1,496 @@
package querybuilder
import (
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
"github.com/antlr4-go/antlr/v4"
"github.com/huandu/go-sqlbuilder"
)
// havingExpressionRewriteVisitor walks the parse tree of a HavingExpression in a single
// pass, simultaneously rewriting user-facing references to their SQL column names and
// collecting any references that could not be resolved.
//
// Each visit method reconstructs the expression string for its subtree:
// - Structural nodes (orExpression, andExpression, comparison, arithmetic) are
// reconstructed with canonical spacing.
// - andExpression joins ALL primaries with " AND ", which naturally normalises any
// implicit-AND adjacency (the old normalizeImplicitAND step).
// - IdentifierContext looks the name up in columnMap; if found the SQL column name is
// returned. If the name is already a valid SQL column (TO side of columnMap) it is
// passed through unchanged. Otherwise it is added to invalid.
// - FunctionCallContext looks the full call text (without whitespace, since WS is
// skipped) up in columnMap; if found the SQL column name is returned, otherwise the
// function name is added to invalid without recursing into its arguments.
// The grammar now accepts complex function arguments (nested calls, string predicates),
// so all aggregation expression forms can be looked up directly via ctx.GetText().
// - STRING atoms (string literals in comparison position) set hasStringLiteral so a
// friendly "aggregator results are numeric" error can be returned.
type havingExpressionRewriteVisitor struct {
columnMap map[string]string
validColumns map[string]bool // TO-side values; identifiers already in SQL form pass through
invalid []string
seen map[string]bool
hasStringLiteral bool
sb *sqlbuilder.SelectBuilder
}
func newHavingExpressionRewriteVisitor(columnMap map[string]string) *havingExpressionRewriteVisitor {
validColumns := make(map[string]bool, len(columnMap))
for _, col := range columnMap {
validColumns[col] = true
}
return &havingExpressionRewriteVisitor{
columnMap: columnMap,
validColumns: validColumns,
seen: make(map[string]bool),
sb: sqlbuilder.NewSelectBuilder(),
}
}
func (v *havingExpressionRewriteVisitor) visitQuery(ctx grammar.IQueryContext) string {
if ctx.Expression() == nil {
return ""
}
return v.visitExpression(ctx.Expression())
}
func (v *havingExpressionRewriteVisitor) visitExpression(ctx grammar.IExpressionContext) string {
return v.visitOrExpression(ctx.OrExpression())
}
func (v *havingExpressionRewriteVisitor) visitOrExpression(ctx grammar.IOrExpressionContext) string {
andExprs := ctx.AllAndExpression()
parts := make([]string, len(andExprs))
for i, ae := range andExprs {
parts[i] = v.visitAndExpression(ae)
}
if len(parts) == 1 {
return parts[0]
}
return v.sb.Or(parts...)
}
// visitAndExpression joins ALL primaries with " AND ".
// The grammar rule `primary ( AND primary | primary )*` allows adjacent primaries
// without an explicit AND (implicit AND). Joining all of them with " AND " here is
// equivalent to the old normalizeImplicitAND step.
func (v *havingExpressionRewriteVisitor) visitAndExpression(ctx grammar.IAndExpressionContext) string {
primaries := ctx.AllPrimary()
parts := make([]string, len(primaries))
for i, p := range primaries {
parts[i] = v.visitPrimary(p)
}
if len(parts) == 1 {
return parts[0]
}
return v.sb.And(parts...)
}
func (v *havingExpressionRewriteVisitor) visitPrimary(ctx grammar.IPrimaryContext) string {
if ctx.OrExpression() != nil {
inner := v.visitOrExpression(ctx.OrExpression())
if ctx.NOT() != nil {
return v.sb.Not(inner)
}
return v.sb.And(inner)
}
if ctx.Comparison() == nil {
return ""
}
inner := v.visitComparison(ctx.Comparison())
if ctx.NOT() != nil {
return v.sb.Not(inner)
}
return inner
}
func (v *havingExpressionRewriteVisitor) visitComparison(ctx grammar.IComparisonContext) string {
if ctx.IN() != nil {
if ctx.Operand(0) == nil || ctx.InList() == nil {
return ""
}
lhs := v.visitOperand(ctx.Operand(0))
signedNumbers := ctx.InList().AllSignedNumber()
vals := make([]interface{}, len(signedNumbers))
for i, n := range signedNumbers {
vals[i] = sqlbuilder.Raw(n.GetText())
}
if ctx.NOT() != nil {
// Here we need to compile because In generates lhs IN $1 syntax
sql, _ := v.sb.Args.CompileWithFlavor(v.sb.NotIn(lhs, vals...), sqlbuilder.ClickHouse)
return sql
}
// Here we need to compile because In generates lhs IN $1 syntax
sql, _ := v.sb.Args.CompileWithFlavor(v.sb.In(lhs, vals...), sqlbuilder.ClickHouse)
return sql
}
if ctx.CompOp() == nil || ctx.Operand(0) == nil || ctx.Operand(1) == nil {
return ""
}
lhs := v.visitOperand(ctx.Operand(0))
op := ctx.CompOp().GetText()
rhs := v.visitOperand(ctx.Operand(1))
return lhs + " " + op + " " + rhs
}
func (v *havingExpressionRewriteVisitor) visitOperand(ctx grammar.IOperandContext) string {
if ctx.Operand() != nil {
left := v.visitOperand(ctx.Operand())
right := v.visitTerm(ctx.Term())
op := "+"
if ctx.MINUS() != nil {
op = "-"
}
return left + " " + op + " " + right
}
return v.visitTerm(ctx.Term())
}
func (v *havingExpressionRewriteVisitor) visitTerm(ctx grammar.ITermContext) string {
if ctx.Term() != nil {
left := v.visitTerm(ctx.Term())
right := v.visitFactor(ctx.Factor())
op := "*"
if ctx.SLASH() != nil {
op = "/"
} else if ctx.PERCENT() != nil {
op = "%"
}
return left + " " + op + " " + right
}
return v.visitFactor(ctx.Factor())
}
func (v *havingExpressionRewriteVisitor) visitFactor(ctx grammar.IFactorContext) string {
if ctx.Factor() != nil {
// Unary sign: (PLUS | MINUS) factor
sign := "+"
if ctx.MINUS() != nil {
sign = "-"
}
return sign + v.visitFactor(ctx.Factor())
}
if ctx.Operand() != nil {
return v.sb.And(v.visitOperand(ctx.Operand()))
}
if ctx.Atom() == nil {
return ""
}
return v.visitAtom(ctx.Atom())
}
func (v *havingExpressionRewriteVisitor) visitAtom(ctx grammar.IAtomContext) string {
if ctx.FunctionCall() != nil {
return v.visitFunctionCall(ctx.FunctionCall())
}
if ctx.Identifier() != nil {
return v.visitIdentifier(ctx.Identifier())
}
if ctx.STRING() != nil {
// String literals are never valid aggregation results; flag for a friendly error.
v.hasStringLiteral = true
return ctx.STRING().GetText()
}
text := ctx.NUMBER().GetText()
return text
}
// visitFunctionCall looks the full call text up in columnMap. WS tokens are skipped by
// the lexer, so ctx.GetText() returns the expression with all whitespace removed
// (e.g. "countIf(level='error')", "avg(sum(cpu_usage))", "count_distinct(a,b)").
// The column map stores both the original expression and a space-stripped version as
// keys, so the lookup is whitespace-insensitive regardless of how the user typed it.
// If not found, the function name is recorded as invalid.
func (v *havingExpressionRewriteVisitor) visitFunctionCall(ctx grammar.IFunctionCallContext) string {
fullText := ctx.GetText()
if col, ok := v.columnMap[fullText]; ok {
return col
}
funcName := ctx.IDENTIFIER().GetText()
if !v.seen[funcName] {
v.invalid = append(v.invalid, funcName)
v.seen[funcName] = true
}
return fullText
}
// visitIdentifier looks the identifier up in columnMap. If found, returns the SQL
// column name. If the name is already a valid SQL column (validColumns), it is passed
// through unchanged — this handles cases where the user writes the SQL column name
// directly (e.g. __result_0). Otherwise records it as invalid.
func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifierContext) string {
name := ctx.IDENTIFIER().GetText()
if col, ok := v.columnMap[name]; ok {
return col
}
if v.validColumns[name] {
return name
}
if !v.seen[name] {
v.invalid = append(v.invalid, name)
v.seen[name] = true
}
return name
}
// rewriteAndValidate is the single-pass implementation used by all RewriteFor* methods.
//
// Validation layers:
// 1. The visitor runs on the parse tree, rewriting and collecting invalid references.
// Unknown references (including unrecognised function calls) → lists valid references.
// The grammar now supports complex function arguments (nested calls, string predicates)
// so all aggregation expression forms are handled directly by the parser without any
// regex pre-substitution.
// 2. String literals in comparison-operand position → descriptive error
// ("aggregator results are numeric").
// 3. ANTLR syntax errors → error with messages referencing the original token names.
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
original := strings.TrimSpace(expression)
// Parse the expression once.
input := antlr.NewInputStream(expression)
lexer := grammar.NewHavingExpressionLexer(input)
lexerErrListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrListener)
tokens := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel)
p := grammar.NewHavingExpressionParser(tokens)
parserErrListener := NewErrorListener()
p.RemoveErrorListeners()
p.AddErrorListener(parserErrListener)
tree := p.Query()
// Layer 1 run the combined visitor and report any unresolved references.
// This runs before the syntax error check so that expressions with recoverable
// parse errors (e.g. sum(count())) still produce an actionable "invalid reference"
// message rather than a raw syntax error.
v := newHavingExpressionRewriteVisitor(r.columnMap)
result := v.visitQuery(tree)
// Layer 2 string literals in comparison-operand position (atom rule).
// The grammar accepts STRING tokens in atom so the parser can recover and continue,
// but the visitor flags them; aggregator results are always numeric.
// This is checked before invalid references so that "contains string literals" takes
// priority when a bare string literal is also an unresolvable operand.
if v.hasStringLiteral {
return "", errors.NewInvalidInputf(
errors.CodeInvalidInput,
"`Having` expression contains string literals",
).WithAdditional("Aggregator results are numeric")
}
if len(v.invalid) > 0 {
sort.Strings(v.invalid)
validKeys := make([]string, 0, len(r.columnMap))
for k := range r.columnMap {
validKeys = append(validKeys, k)
}
sort.Strings(validKeys)
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
if len(v.invalid) == 1 {
inv := v.invalid[0]
// Only suggest for plain identifier typos, not for unresolved function
// calls: a function call will appear as "name(" in the expression, and
// the closest valid key may itself contain "(" (e.g. "sum(a)"), making
// a simple string substitution produce a corrupt expression.
isFuncCall := strings.Contains(original, inv+"(")
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
corrected := strings.ReplaceAll(original, inv, match)
additional = append(additional, "Suggestion: `"+corrected+"`")
}
}
return "", errors.NewInvalidInputf(
errors.CodeInvalidInput,
"Invalid references in `Having` expression: [%s]",
strings.Join(v.invalid, ", "),
).WithAdditional(additional...)
}
// Layer 3 ANTLR syntax errors. We parse the original expression, so error messages
// already reference the user's own token names; no re-parsing is needed.
allSyntaxErrors := append(lexerErrListener.SyntaxErrors, parserErrListener.SyntaxErrors...)
if len(allSyntaxErrors) > 0 {
msgs := make([]string, 0, len(allSyntaxErrors))
for _, se := range allSyntaxErrors {
if m := se.Error(); m != "" {
msgs = append(msgs, m)
}
}
detail := strings.Join(msgs, "; ")
if detail == "" {
detail = "check the expression syntax"
}
additional := []string{detail}
// For single-error expressions, try to produce an actionable suggestion.
if len(allSyntaxErrors) == 1 {
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
additional = append(additional, "Suggestion: `"+s+"`")
}
}
return "", errors.NewInvalidInputf(
errors.CodeInvalidInput,
"Syntax error in `Having` expression",
).WithAdditional(additional...)
}
return result, nil
}
// havingSuggestion returns a corrected expression string to show as a suggestion when
// the error matches a well-known single-mistake pattern, or "" when no suggestion
// can be formed. Only call this when there is exactly one syntax error.
//
// Recognised patterns (all produce a minimal, valid completion):
// 1. Bare aggregation — comparison operator expected at EOF: count() → count() > 0
// 2. Missing right operand after comparison op at EOF: count() > → count() > 0
// 3. Unclosed parenthesis — only ) expected at EOF: (total > 100 → (total > 100)
// 4. Dangling AND/OR at end of expression: total > 100 AND → total > 100
// 5. Leading OR at position 0: OR total > 100 → total > 100
func havingSuggestion(se *SyntaxErr, original string) string {
trimmed := strings.TrimSpace(original)
upper := strings.ToUpper(trimmed)
if se.TokenTxt == "EOF" {
// Pattern 4: dangling AND or OR at end of expression.
// e.g. total > 100 AND → total > 100
// Checked before Pattern 1 so that "expr AND" does not match Pattern 1.
if strings.HasSuffix(upper, " AND") {
return strings.TrimSpace(trimmed[:len(trimmed)-4])
}
if strings.HasSuffix(upper, " OR") {
return strings.TrimSpace(trimmed[:len(trimmed)-3])
}
// Pattern 1: bare aggregation reference — no comparison operator yet.
// Detected by: IDENTIFIER in expected (operand-continuation set), expression
// does not already end with a comparison operator (Pattern 2 handles that case),
// and no unclosed parenthesis (Pattern 3 handles that case).
// e.g. count() → count() > 0
// total_logs → total_logs > 0
if expectedContains(se, "IDENTIFIER") && !endsWithComparisonOp(trimmed) && !hasUnclosedParen(trimmed) {
return trimmed + " > 0"
}
// Pattern 2: comparison operator already written but right operand missing.
// e.g. count() > → count() > 0
if expectedContains(se, "number") && endsWithComparisonOp(trimmed) {
return trimmed + " 0"
}
// Pattern 3: unclosed parenthesis with content inside.
// e.g. (total > 100 AND count() < 500 → (total > 100 AND count() < 500)
// Guard len > 1 avoids a useless "()" suggestion for a bare "(".
if expectedContains(se, ")") && hasUnclosedParen(trimmed) && len(trimmed) > 1 {
return trimmed + ")"
}
// Pattern 6: unclosed IN bracket list.
// e.g. count() IN [1, 2, 3 → count() IN [1, 2, 3]
if expectedContains(se, "]") && hasUnclosedBracket(trimmed) && len(trimmed) > 1 {
return trimmed + "]"
}
return ""
}
// Pattern 5: leading OR at position 0.
// e.g. OR total > 100 → total > 100
if se.TokenTxt == "'OR'" && se.Col == 0 && strings.HasPrefix(upper, "OR ") {
return strings.TrimSpace(trimmed[3:])
}
return ""
}
// expectedContains reports whether label is present in se.Expected.
func expectedContains(se *SyntaxErr, label string) bool {
for _, e := range se.Expected {
if e == label {
return true
}
}
return false
}
// hasUnclosedParen reports whether s contains more '(' than ')'.
func hasUnclosedParen(s string) bool {
count := 0
for _, c := range s {
switch c {
case '(':
count++
case ')':
count--
}
}
return count > 0
}
// hasUnclosedBracket reports whether s contains more '[' than ']'.
func hasUnclosedBracket(s string) bool {
count := 0
for _, c := range s {
switch c {
case '[':
count++
case ']':
count--
}
}
return count > 0
}
// closestMatch returns the element of candidates with the smallest Levenshtein
// distance to query, along with that distance.
func closestMatch(query string, candidates []string) (string, int) {
best, bestDist := "", -1
for _, c := range candidates {
if d := levenshtein(query, c); bestDist < 0 || d < bestDist {
best, bestDist = c, d
}
}
return best, bestDist
}
// levenshtein computes the edit distance between a and b.
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
row := make([]int, lb+1)
for j := range row {
row[j] = j
}
for i := 1; i <= la; i++ {
prev := row[0]
row[0] = i
for j := 1; j <= lb; j++ {
tmp := row[j]
if ra[i-1] == rb[j-1] {
row[j] = prev
} else {
row[j] = 1 + min(prev, min(row[j], row[j-1]))
}
prev = tmp
}
}
return row[lb]
}
// endsWithComparisonOp reports whether s ends with a comparison operator token
// (longer operators are checked first to avoid ">=" being matched by ">").
func endsWithComparisonOp(s string) bool {
for _, op := range []string{">=", "<=", "!=", "<>", "==", ">", "<", "="} {
if strings.HasSuffix(s, op) {
return true
}
}
return false
}

View File

@@ -2,7 +2,6 @@ package querybuilder
import (
"fmt"
"regexp"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -19,19 +18,31 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
}
}
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) string {
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.buildTraceColumnMap(aggregations)
return r.rewriteExpression(expression)
return r.rewriteAndValidate(expression)
}
func (r *HavingExpressionRewriter) RewriteForLogs(expression string, aggregations []qbtypes.LogAggregation) string {
// RewriteForLogs rewrites and validates the HAVING expression for a logs query.
func (r *HavingExpressionRewriter) RewriteForLogs(expression string, aggregations []qbtypes.LogAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.buildLogColumnMap(aggregations)
return r.rewriteExpression(expression)
return r.rewriteAndValidate(expression)
}
func (r *HavingExpressionRewriter) RewriteForMetrics(expression string, aggregations []qbtypes.MetricAggregation) string {
// RewriteForMetrics rewrites and validates the HAVING expression for a metrics query.
func (r *HavingExpressionRewriter) RewriteForMetrics(expression string, aggregations []qbtypes.MetricAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.buildMetricColumnMap(aggregations)
return r.rewriteExpression(expression)
return r.rewriteAndValidate(expression)
}
func (r *HavingExpressionRewriter) buildTraceColumnMap(aggregations []qbtypes.TraceAggregation) {
@@ -45,6 +56,9 @@ func (r *HavingExpressionRewriter) buildTraceColumnMap(aggregations []qbtypes.Tr
}
r.columnMap[agg.Expression] = sqlColumn
if normalized := strings.ReplaceAll(agg.Expression, " ", ""); normalized != agg.Expression {
r.columnMap[normalized] = sqlColumn
}
r.columnMap[fmt.Sprintf("__result%d", idx)] = sqlColumn
@@ -65,6 +79,9 @@ func (r *HavingExpressionRewriter) buildLogColumnMap(aggregations []qbtypes.LogA
}
r.columnMap[agg.Expression] = sqlColumn
if normalized := strings.ReplaceAll(agg.Expression, " ", ""); normalized != agg.Expression {
r.columnMap[normalized] = sqlColumn
}
r.columnMap[fmt.Sprintf("__result%d", idx)] = sqlColumn
@@ -102,52 +119,3 @@ func (r *HavingExpressionRewriter) buildMetricColumnMap(aggregations []qbtypes.M
r.columnMap[fmt.Sprintf("__result%d", idx)] = sqlColumn
}
}
func (r *HavingExpressionRewriter) rewriteExpression(expression string) string {
quotedStrings := make(map[string]string)
quotePattern := regexp.MustCompile(`'[^']*'|"[^"]*"`)
quotedIdx := 0
expression = quotePattern.ReplaceAllStringFunc(expression, func(match string) string {
placeholder := fmt.Sprintf("__QUOTED_%d__", quotedIdx)
quotedStrings[placeholder] = match
quotedIdx++
return placeholder
})
type mapping struct {
from string
to string
}
mappings := make([]mapping, 0, len(r.columnMap))
for from, to := range r.columnMap {
mappings = append(mappings, mapping{from: from, to: to})
}
for i := 0; i < len(mappings); i++ {
for j := i + 1; j < len(mappings); j++ {
if len(mappings[j].from) > len(mappings[i].from) {
mappings[i], mappings[j] = mappings[j], mappings[i]
}
}
}
for _, m := range mappings {
if strings.Contains(m.from, "(") {
// escape special regex characters in the function name
escapedFrom := regexp.QuoteMeta(m.from)
pattern := regexp.MustCompile(`\b` + escapedFrom)
expression = pattern.ReplaceAllString(expression, m.to)
} else {
pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(m.from) + `\b`)
expression = pattern.ReplaceAllString(expression, m.to)
}
}
for placeholder, original := range quotedStrings {
expression = strings.Replace(expression, placeholder, original, 1)
}
return expression
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
)

View File

@@ -1,7 +1,7 @@
package querybuilder
import (
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)

View File

@@ -9,7 +9,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"

View File

@@ -7,7 +7,7 @@ import (
"strings"
"testing"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"

View File

@@ -104,8 +104,15 @@ func extractCHOriginFieldFromQuery(query string) (string, error) {
return "", errors.NewInternalf(errors.CodeInternal, "failed to parse origin field from query: %s", err.Error())
}
if len(stmts) == 0 {
return "", errors.NewInternalf(errors.CodeInternal, "no statements found in query")
}
// Get the first statement which should be a SELECT
selectStmt := stmts[0].(*parser.SelectQuery)
selectStmt, ok := stmts[0].(*parser.SelectQuery)
if !ok {
return "", errors.NewInternalf(errors.CodeInternal, "expected SELECT query, got %T", stmts[0])
}
// If query has multiple select items, return blank string as we don't expect multiple select items
if len(selectStmt.SelectItems) > 1 {

View File

@@ -2,6 +2,7 @@ package queryparser
import (
"context"
"fmt"
"strings"
@@ -23,7 +24,15 @@ func New(settings factory.ProviderSettings) QueryParser {
}
}
func (p *queryParserImpl) AnalyzeQueryFilter(ctx context.Context, queryType qbtypes.QueryType, query string) (*queryfilterextractor.FilterResult, error) {
func (p *queryParserImpl) AnalyzeQueryFilter(ctx context.Context, queryType qbtypes.QueryType, query string) (result *queryfilterextractor.FilterResult, err error) {
// the third-party clickhouse sql parser can panic on certain inputs, recover gracefully
defer func() {
if r := recover(); r != nil {
result = nil
err = errors.NewInternalf(errors.CodeInternal, "failed to analyze query filter: %s", fmt.Sprint(r))
}
}()
var extractorType queryfilterextractor.ExtractorType
switch queryType {
case qbtypes.QueryTypePromQL:

View File

@@ -139,8 +139,7 @@ func (r *rule) GetStoredRulesByMetricName(ctx context.Context, orgID string, met
// Check conditions: must be metric-based alert with valid composite query
if ruleData.AlertType != ruletypes.AlertTypeMetric ||
ruleData.RuleCondition == nil ||
ruleData.RuleCondition.CompositeQuery == nil {
ruleData.RuleCondition == nil {
continue
}

View File

@@ -194,7 +194,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "ERROR: cannot execute update() in a read-only context",
shouldPass: false,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got ')'",
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got ')'",
},
{
category: "Special characters",
@@ -616,7 +616,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Keyword conflict",
@@ -2016,7 +2016,7 @@ func TestFilterExprLogs(t *testing.T) {
expectedErrorContains: "",
},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'AND'"},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'AND'"},
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'OR'"},
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got EOF"},
@@ -2160,7 +2160,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Operator keywords as keys",

View File

@@ -427,7 +427,10 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
if query.Having != nil && query.Having.Expression != "" {
// Rewrite having expression to use SQL column names
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
@@ -453,7 +456,10 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
@@ -560,7 +566,10 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
// Add having clause if needed
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHavingExpressionRewriter_LogQueries(t *testing.T) {
@@ -14,6 +15,7 @@ func TestHavingExpressionRewriter_LogQueries(t *testing.T) {
havingExpression string
aggregations []qbtypes.LogAggregation
expectedExpression string
wantErr bool
}{
{
name: "single aggregation with alias",
@@ -30,7 +32,7 @@ func TestHavingExpressionRewriter_LogQueries(t *testing.T) {
{Expression: "count()", Alias: "total"},
{Expression: "avg(duration)", Alias: "avg_duration"},
},
expectedExpression: "(__result_0 > 100 AND __result_1 < 500) OR __result_0 > 10000",
expectedExpression: "(((__result_0 > 100 AND __result_1 < 500)) OR __result_0 > 10000)",
},
{
name: "__result reference for single aggregation",
@@ -55,7 +57,7 @@ func TestHavingExpressionRewriter_LogQueries(t *testing.T) {
{Expression: "count()", Alias: ""},
{Expression: "sum(bytes)", Alias: ""},
},
expectedExpression: "__result_0 > 100 AND __result_1 < 1000",
expectedExpression: "(__result_0 > 100 AND __result_1 < 1000)",
},
{
name: "mixed aliases and expressions",
@@ -64,15 +66,36 @@ func TestHavingExpressionRewriter_LogQueries(t *testing.T) {
{Expression: "count()", Alias: ""},
{Expression: "countIf(level='error')", Alias: "error_count"},
},
expectedExpression: "__result_1 > 10 AND __result_0 < 1000",
expectedExpression: "(__result_1 > 10 AND __result_0 < 1000)",
},
{
name: "string literal in having expression",
havingExpression: "count() > 'threshold'",
aggregations: []qbtypes.LogAggregation{
{Expression: "count()", Alias: ""},
},
wantErr: true,
},
{
name: "unknown reference",
havingExpression: "no_such_alias > 100",
aggregations: []qbtypes.LogAggregation{
{Expression: "count()", Alias: "total_logs"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rewriter := querybuilder.NewHavingExpressionRewriter()
result := rewriter.RewriteForLogs(tt.havingExpression, tt.aggregations)
assert.Equal(t, tt.expectedExpression, result)
result, err := rewriter.RewriteForLogs(tt.havingExpression, tt.aggregations)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedExpression, result)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More