Compare commits

...

3 Commits

Author SHA1 Message Date
nityanandagohain
bac667467f fix: minor changes 2026-09-08 12:35:52 +05:30
nityanandagohain
a79ecace96 fix: rule state history changes 2026-09-08 11:54:40 +05:30
nityanandagohain
224671f84f feat: support ai trace alerts 2026-09-07 17:54:48 +05:30
21 changed files with 427 additions and 52 deletions

View File

@@ -7939,6 +7939,8 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -7982,6 +7984,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8087,6 +8091,7 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:

View File

@@ -9098,6 +9098,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9146,6 +9150,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9241,6 +9249,7 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',

View File

@@ -12,25 +12,32 @@ import (
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
Filter: &FilterExpression{Expression: whereClause},
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryFormulas: make([]string, 0),
},
@@ -47,7 +54,8 @@ func prepareExplorerParams(dataSource string, start, end int64, whereClause stri
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
@@ -63,7 +71,7 @@ func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal teleme
var q qbtypes.QueryBuilderQuery[T]
found := false
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {

View File

@@ -30,6 +30,15 @@ func TestBuilderQueryForSignal(t *testing.T) {
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
@@ -46,6 +55,14 @@ func TestBuilderQueryForSignal(t *testing.T) {
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)

View File

@@ -13,8 +13,9 @@ type FilterExpression struct {
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
DataSource string `json:"dataSource"`
Filter *FilterExpression `json:"filter,omitempty"`
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`
}
type URLShareableBuilderQuery struct {

View File

@@ -114,6 +114,7 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
Fingerprint: item.Fingerprint,
Value: item.Value,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
@@ -151,11 +152,12 @@ func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Requ
converted := make([]rulestatehistorytypes.GettableRuleStateHistoryContributor, 0, len(res))
for _, item := range res {
converted = append(converted, rulestatehistorytypes.GettableRuleStateHistoryContributor{
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedLogsLink: item.RelatedLogsLink,
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
render.Success(w, http.StatusOK, converted)

View File

@@ -41,7 +41,8 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
signal, ok := relatedLinkSignal(rule.AlertType)
if !ok {
return nil
}
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
@@ -62,10 +63,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
signal := telemetrytypes.SignalLogs
if rule.AlertType == ruletypes.AlertTypeTraces {
signal = telemetrytypes.SignalTraces
}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
@@ -84,21 +81,35 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
return start.Add(-3 * time.Minute), end
}
// links returns the encoded logs and traces explorer query params for the
// given entry labels and time range; at most one of the two is non-empty.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) (string, string) {
// links returns the explorer query params for the given entry labels and time
// range.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
lbls := map[string]string{}
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
return "", ""
return rulestatehistorytypes.RelatedLinks{}
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
switch b.alertType {
case ruletypes.AlertTypeLogs:
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
case ruletypes.AlertTypeTraces:
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
case ruletypes.AlertTypeAITraces:
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
}
return "", ""
return rulestatehistorytypes.RelatedLinks{}
}
// relatedLinkSignal returns the explorer signal that related links open for
// the alert type, or ok=false when the alert type has none (e.g. metrics).
func relatedLinkSignal(alertType ruletypes.AlertType) (telemetrytypes.Signal, bool) {
switch alertType {
case ruletypes.AlertTypeLogs:
return telemetrytypes.SignalLogs, true
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
return telemetrytypes.SignalTraces, true
}
return telemetrytypes.SignalUnspecified, false
}

View File

@@ -34,7 +34,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
for idx := range items {
start, end := builder.queryWindow(items[idx].UnixMilli)
items[idx].RelatedLogsLink, items[idx].RelatedTracesLink = builder.links(items[idx].Labels, start, end)
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
}
}
@@ -60,7 +60,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
// span it too instead of a single evaluation window
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
for idx := range contributors {
contributors[idx].RelatedLogsLink, contributors[idx].RelatedTracesLink = builder.links(contributors[idx].Labels, start, end)
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
}
}

View File

@@ -854,12 +854,12 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces {
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
// 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 {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
@@ -873,7 +873,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
}
}
}

View File

@@ -420,7 +420,7 @@ func (r *BaseRule) ShouldSkipNewGroups() bool {
func (r *BaseRule) isFilterNewSeriesSupported() bool {
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
for _, query := range r.ruleCondition.CompositeQuery.Queries {
if query.Type != qbtypes.QueryTypeBuilder {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
continue
}
switch query.Spec.(type) {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -164,6 +165,24 @@ type filterNewSeriesTestCase struct {
expectError bool
}
func TestBaseRule_IsFilterNewSeriesSupported(t *testing.T) {
postableRule := createPostableRule(&ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "max(trace.total_tokens)"}},
},
}},
})
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, mustParseURL(t, "http://localhost:8080"), WithLogger(instrumentationtest.New().Logger()))
require.NoError(t, err)
assert.False(t, rule.isFilterNewSeriesSupported())
}
func TestBaseRule_FilterNewSeries(t *testing.T) {
defaultEvalTime := time.Unix(1700000000, 0)
defaultNewGroupEvalDelay := valuer.MustParseTextDuration("2m")

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/metricsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
@@ -127,3 +128,40 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
0, // maxConcurrentQueries (0 means default)
)
}
func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
t.Helper()
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalTraces
}
}
metadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
require.NoError(t, err)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
aiTraceStmtBuilder,
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
)
}

View File

@@ -132,7 +132,7 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
@@ -308,10 +308,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
// is used alert grouping, and we want to group alerts with the same
// label set, but different timestamps, together.
switch r.typ {
case ruletypes.AlertTypeTraces:
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("traces-explorer", params)
explorerPath := "traces-explorer"
if r.typ == ruletypes.AlertTypeAITraces {
explorerPath = "ai-observability/explorer"
}
link := r.ExternalURL(explorerPath, params)
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
}

View File

@@ -916,6 +916,104 @@ func TestThresholdRuleTracesLink(t *testing.T) {
}
}
func TestThresholdRuleAITracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "AI traces link test",
AlertType: ruletypes.AlertTypeAITraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.TraceAggregation{{
Expression: "count()",
}},
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'llm-gateway'",
},
},
}},
},
},
}
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "timestamp", Type: "DateTime"})
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
logger := instrumentationtest.New().Logger()
for idx, c := range testCases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
rows := cmock.NewRows(cols, c.values)
telemetryStore.Mock().
ExpectQuery("SELECT any").
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
},
},
}
postableRule.Annotations = map[string]string{
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
require.NoError(t, err, "case %d", idx)
alertsFound, err := rule.Eval(context.Background(), time.Now())
require.NoError(t, err, "case %d", idx)
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
link := item.Annotations.Map()[ruletypes.AnnotationRelatedTraces]
assert.True(t, strings.HasPrefix(link, "http://localhost:8080/ai-observability/explorer?"), "case %d: %s", idx, link)
assert.Contains(t, link, "builder_ai_query", "case %d", idx)
assert.Contains(t, link, "llm-gateway", "case %d", idx)
}
}
}
func TestThresholdRuleLogsLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Logs link test",

View File

@@ -23,15 +23,17 @@ type GettableRuleStateHistory struct {
Fingerprint uint64 `json:"fingerprint" required:"true"`
Value float64 `json:"value" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedAITracesLink string `json:"relatedAITracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
}
type GettableRuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint" required:"true"`
Labels []*qbtypes.Label `json:"labels" required:"true"`
Count uint64 `json:"count" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
Fingerprint uint64 `json:"fingerprint" required:"true"`
Labels []*qbtypes.Label `json:"labels" required:"true"`
Count uint64 `json:"count" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedAITracesLink string `json:"relatedAITracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
}
type GettableRuleStateWindow struct {

View File

@@ -77,16 +77,23 @@ type RuleStateHistory struct {
Fingerprint uint64 `ch:"fingerprint"`
Value float64 `ch:"value"`
RelatedTracesLink string
RelatedLogsLink string
RelatedLinks
}
type RuleStateHistoryContributor struct {
Fingerprint uint64 `ch:"fingerprint"`
Labels LabelsString `ch:"labels"`
Count uint64 `ch:"count"`
RelatedTracesLink string
RelatedLogsLink string
Fingerprint uint64 `ch:"fingerprint"`
Labels LabelsString `ch:"labels"`
Count uint64 `ch:"count"`
RelatedLinks
}
// RelatedLinks holds the encoded explorer query params for a history entry;
// at most one field is non-empty.
type RelatedLinks struct {
RelatedTracesLink string
RelatedAITracesLink string
RelatedLogsLink string
}
type Store interface {

View File

@@ -24,6 +24,7 @@ const (
AlertTypeTraces AlertType = "TRACES_BASED_ALERT"
AlertTypeLogs AlertType = "LOGS_BASED_ALERT"
AlertTypeExceptions AlertType = "EXCEPTIONS_BASED_ALERT"
AlertTypeAITraces AlertType = "AI_TRACES_BASED_ALERT"
)
// Enum implements jsonschema.Enum; returns the acceptable values for AlertType.
@@ -33,9 +34,19 @@ func (AlertType) Enum() []any {
AlertTypeTraces,
AlertTypeLogs,
AlertTypeExceptions,
AlertTypeAITraces,
}
}
// BuilderQueryType returns the query type the alert type's builder queries
// carry; only AI trace alerts use builder_ai_query.
func (t AlertType) BuilderQueryType() qbtypes.QueryType {
if t == AlertTypeAITraces {
return qbtypes.QueryTypeBuilderAI
}
return qbtypes.QueryTypeBuilder
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
@@ -406,11 +417,11 @@ func (r *PostableRule) Validate() error {
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
}
}

View File

@@ -209,6 +209,10 @@ func TestValidate_PostableRule_Common(t *testing.T) {
name: "valid alertType EXCEPTIONS_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "EXCEPTIONS_BASED_ALERT"}`),
},
{
name: "valid alertType AI_TRACES_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "AI_TRACES_BASED_ALERT"}`),
},
{
name: "empty alertType is ok (optional)",
json: removeField(validV1Builder(), "alertType"),

View File

@@ -0,0 +1,16 @@
{"timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "c1b2c3d4e5f6a701", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "d1b2c3d4e5f6a701", "parent_span_id": "c1b2c3d4e5f6a701", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 300, "gen_ai.usage.output_tokens": 120, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "c1b2c3d4e5f6a702", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "d1b2c3d4e5f6a702", "parent_span_id": "c1b2c3d4e5f6a702", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 310, "gen_ai.usage.output_tokens": 125, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "c1b2c3d4e5f6a703", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "d1b2c3d4e5f6a703", "parent_span_id": "c1b2c3d4e5f6a703", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 320, "gen_ai.usage.output_tokens": 130, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "c1b2c3d4e5f6a704", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "d1b2c3d4e5f6a704", "parent_span_id": "c1b2c3d4e5f6a704", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 330, "gen_ai.usage.output_tokens": 135, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "c1b2c3d4e5f6a705", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "d1b2c3d4e5f6a705", "parent_span_id": "c1b2c3d4e5f6a705", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 340, "gen_ai.usage.output_tokens": 140, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "c1b2c3d4e5f6a706", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "d1b2c3d4e5f6a706", "parent_span_id": "c1b2c3d4e5f6a706", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 350, "gen_ai.usage.output_tokens": 145, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "c1b2c3d4e5f6a707", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "d1b2c3d4e5f6a707", "parent_span_id": "c1b2c3d4e5f6a707", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 360, "gen_ai.usage.output_tokens": 150, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "c1b2c3d4e5f6a708", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "d1b2c3d4e5f6a708", "parent_span_id": "c1b2c3d4e5f6a708", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 370, "gen_ai.usage.output_tokens": 155, "_signoz.gen_ai.total_cost": 0.02}}

View File

@@ -0,0 +1,73 @@
{
"alert": "rule_state_history_ai_traces",
"ruleType": "threshold_rule",
"alertType": "AI_TRACES_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_ai_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "trace.input_tokens > 100"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "max(trace.total_tokens)"
}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -38,6 +38,7 @@ def test_logs_rule_history_related_links(
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
assert item.get("relatedTracesLink", "") == ""
assert item.get("relatedAITracesLink", "") == ""
assert item.get("relatedLogsLink", "") != ""
# logs explorer links carry the time range in milliseconds, anchored to the
@@ -52,6 +53,7 @@ def test_logs_rule_history_related_links(
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedTracesLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") == ""
assert contributors[0].get("relatedLogsLink", "") != ""
# contributor counts aggregate the whole queried range, so their links span it
@@ -80,6 +82,7 @@ def test_traces_rule_history_related_links(
assert labels_to_map(item["labels"]).get("service.name") == "order-service"
assert item.get("relatedLogsLink", "") == ""
assert item.get("relatedAITracesLink", "") == ""
assert item.get("relatedTracesLink", "") != ""
# traces explorer links carry the time range in nanoseconds, anchored to the
@@ -94,6 +97,7 @@ def test_traces_rule_history_related_links(
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedLogsLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") == ""
assert contributors[0].get("relatedTracesLink", "") != ""
# contributor counts aggregate the whole queried range, so their links span it
@@ -101,3 +105,49 @@ def test_traces_rule_history_related_links(
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["http.request.path", "/order", "service.name", "order-service"])
def test_ai_traces_rule_history_related_links(
signoz: types.SigNoz,
create_alert_rule_with_channel: Callable[[str], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
insert_alert_data(
[types.AlertData(type="traces", data_path="alerts/test_scenarios/rule_state_history_ai_traces/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_id = create_alert_rule_with_channel("alerts/test_scenarios/rule_state_history_ai_traces/rule.json")
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
assert labels_to_map(item["labels"]).get("service.name") == "llm-gateway"
assert item.get("relatedLogsLink", "") == ""
assert item.get("relatedTracesLink", "") == ""
assert item.get("relatedAITracesLink", "") != ""
# AI alert links follow the traces explorer shape: a nanosecond range
# anchored to the second-truncated entry timestamp
link = parse_related_link(item["relatedAITracesLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1_000_000_000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1_000_000_000
assert_related_link_query(link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
# the AI explorer only resolves trace.* fields for builder_ai_query
assert link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "llm-gateway"]
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedLogsLink", "") == ""
assert contributors[0].get("relatedTracesLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") != ""
contributor_link = parse_related_link(contributors[0]["relatedAITracesLink"])
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"