mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
SendAlerts addressed every alert to `SELECT id FROM organizations LIMIT 1`, which is whichever org sorts first, not the rule's. That org's alertmanager has none of the rule's channels or route policies, so the alert sat on its default receiver and reached nobody, and that org's members could see it. Alerts now carry the rule's own org id. Co-authored-by: Hanzo Dev <[email protected]>
674 lines
20 KiB
Go
674 lines
20 KiB
Go
package rules
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y/pkg/errors"
|
|
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
|
|
"github.com/hanzoai/o11y/pkg/queryparser"
|
|
"github.com/hanzoai/o11y/pkg/sqlstore"
|
|
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
|
qbtypes "github.com/hanzoai/o11y/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/hanzoai/o11y/pkg/types/rulestatehistorytypes"
|
|
"github.com/hanzoai/o11y/pkg/types/ruletypes"
|
|
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
)
|
|
|
|
// BaseRule contains common fields and methods for all rule types
|
|
type BaseRule struct {
|
|
id string
|
|
name string
|
|
orgID valuer.UUID
|
|
externalURL *url.URL
|
|
handledRestart bool
|
|
|
|
// Type of the rule
|
|
typ ruletypes.AlertType
|
|
|
|
ruleCondition *ruletypes.RuleCondition
|
|
|
|
Threshold ruletypes.RuleThreshold
|
|
// evalWindow is the time window used for evaluating the rule
|
|
// i.e. each time we lookback from the current time, we look at data for the last
|
|
// evalWindow duration
|
|
evalWindow valuer.TextDuration
|
|
// holdDuration is the duration for which the alert waits before firing
|
|
holdDuration valuer.TextDuration
|
|
|
|
// evalDelay is the delay in evaluation of the rule
|
|
// this is useful in cases where the data is not available immediately
|
|
evalDelay valuer.TextDuration
|
|
|
|
// holds the static set of labels and annotations for the rule
|
|
// these are the same for all alerts created for this rule
|
|
labels ruletypes.Labels
|
|
annotations ruletypes.Labels
|
|
// preferredChannels is the list of channels to send the alert to
|
|
// if the rule is triggered
|
|
preferredChannels []string
|
|
mtx sync.Mutex
|
|
// the time it took to evaluate the rule (most recent evaluation)
|
|
evaluationDuration time.Duration
|
|
// the timestamp of the last evaluation
|
|
evaluationTimestamp time.Time
|
|
|
|
health ruletypes.RuleHealth
|
|
lastError error
|
|
Active map[uint64]*ruletypes.Alert
|
|
|
|
// lastTimestampWithDatapoints is the timestamp of the last datapoint we
|
|
// observed for this rule.
|
|
// This is used for missing data alerts.
|
|
lastTimestampWithDatapoints time.Time
|
|
|
|
logger *slog.Logger
|
|
|
|
// sendUnmatched sends observed metric values even if they don't match the
|
|
// rule condition. This is useful in testing the rule.
|
|
sendUnmatched bool
|
|
|
|
// sendAlways will send alert irrespective of resendDelay or other params
|
|
sendAlways bool
|
|
|
|
sqlstore sqlstore.SQLStore
|
|
|
|
metadataStore telemetrytypes.MetadataStore
|
|
|
|
evaluation ruletypes.Evaluation
|
|
|
|
// newGroupEvalDelay is the grace period for new alert groups
|
|
newGroupEvalDelay valuer.TextDuration
|
|
|
|
ruleStateHistoryModule rulestatehistory.Module
|
|
|
|
queryParser queryparser.QueryParser
|
|
}
|
|
|
|
type RuleOption func(*BaseRule)
|
|
|
|
func WithSendAlways() RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.sendAlways = true
|
|
}
|
|
}
|
|
|
|
func WithSendUnmatched() RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.sendUnmatched = true
|
|
}
|
|
}
|
|
|
|
func WithEvalDelay(dur valuer.TextDuration) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.evalDelay = dur
|
|
}
|
|
}
|
|
|
|
func WithLogger(logger *slog.Logger) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.logger = logger.With(slog.String("rule.id", r.id))
|
|
}
|
|
}
|
|
|
|
func WithSQLStore(sqlstore sqlstore.SQLStore) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.sqlstore = sqlstore
|
|
}
|
|
}
|
|
|
|
func WithQueryParser(queryParser queryparser.QueryParser) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.queryParser = queryParser
|
|
}
|
|
}
|
|
|
|
func WithMetadataStore(metadataStore telemetrytypes.MetadataStore) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.metadataStore = metadataStore
|
|
}
|
|
}
|
|
|
|
func WithRuleStateHistoryModule(module rulestatehistory.Module) RuleOption {
|
|
return func(r *BaseRule) {
|
|
r.ruleStateHistoryModule = module
|
|
}
|
|
}
|
|
|
|
func NewBaseRule(
|
|
id string,
|
|
orgID valuer.UUID,
|
|
p *ruletypes.PostableRule,
|
|
externalURL *url.URL,
|
|
opts ...RuleOption,
|
|
) (*BaseRule, error) {
|
|
threshold, err := p.RuleCondition.Thresholds.GetRuleThreshold()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
evaluation, err := p.Evaluation.GetEvaluation()
|
|
if err != nil {
|
|
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
|
|
}
|
|
|
|
baseRule := &BaseRule{
|
|
id: id,
|
|
orgID: orgID,
|
|
externalURL: externalURL,
|
|
name: p.AlertName,
|
|
typ: p.AlertType,
|
|
ruleCondition: p.RuleCondition,
|
|
evalWindow: p.EvalWindow,
|
|
labels: ruletypes.FromMap(p.Labels),
|
|
annotations: ruletypes.FromMap(p.Annotations),
|
|
preferredChannels: p.PreferredChannels,
|
|
health: ruletypes.HealthUnknown,
|
|
Active: map[uint64]*ruletypes.Alert{},
|
|
Threshold: threshold,
|
|
evaluation: evaluation,
|
|
}
|
|
|
|
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
|
|
if p.NotificationSettings != nil {
|
|
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
|
|
}
|
|
|
|
if baseRule.evalWindow.IsZero() {
|
|
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(baseRule)
|
|
}
|
|
|
|
return baseRule, nil
|
|
}
|
|
|
|
func (r *BaseRule) currentAlerts() []*ruletypes.Alert {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
|
|
alerts := make([]*ruletypes.Alert, 0, len(r.Active))
|
|
for _, a := range r.Active {
|
|
anew := *a
|
|
alerts = append(alerts, &anew)
|
|
}
|
|
return alerts
|
|
}
|
|
|
|
// ShouldSendUnmatched returns true if the rule should send unmatched samples
|
|
// during alert evaluation, even if they don't match the rule condition.
|
|
// This is useful in testing the rule.
|
|
func (r *BaseRule) ShouldSendUnmatched() bool {
|
|
return r.sendUnmatched
|
|
}
|
|
|
|
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint and
|
|
// the fingerprint is computed using the QueryResultLables.Hash() method.
|
|
// We use the QueryResultLables instead of labels as these labels are raw labels
|
|
// that we get from the sample.
|
|
// This is useful in cases where we want to check if an alert is still active
|
|
// based on the labels we have.
|
|
func (r *BaseRule) ActiveAlertsLabelFP() map[uint64]struct{} {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
|
|
activeAlerts := make(map[uint64]struct{}, len(r.Active))
|
|
for _, alert := range r.Active {
|
|
if alert == nil || alert.QueryResultLabels == nil {
|
|
continue
|
|
}
|
|
activeAlerts[alert.QueryResultLabels.Hash()] = struct{}{}
|
|
}
|
|
return activeAlerts
|
|
}
|
|
|
|
func (r *BaseRule) EvalDelay() valuer.TextDuration {
|
|
return r.evalDelay
|
|
}
|
|
|
|
func (r *BaseRule) EvalWindow() valuer.TextDuration {
|
|
return r.evalWindow
|
|
}
|
|
|
|
func (r *BaseRule) HoldDuration() valuer.TextDuration {
|
|
return r.holdDuration
|
|
}
|
|
|
|
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() 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 {
|
|
params := url.Values{}
|
|
params.Set("ruleId", r.id)
|
|
return r.ExternalURL("alerts/overview", params)
|
|
}
|
|
|
|
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
|
|
u := r.externalURL.JoinPath(path)
|
|
if len(params) > 0 {
|
|
u.RawQuery = params.Encode()
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func (r *BaseRule) SelectedQuery(ctx context.Context) string {
|
|
if r.ruleCondition.SelectedQuery != "" {
|
|
return r.ruleCondition.SelectedQuery
|
|
}
|
|
r.logger.WarnContext(ctx, "missing selected query")
|
|
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) {
|
|
st, en := r.evaluation.NextWindowFor(ts)
|
|
start := st.UnixMilli()
|
|
end := en.UnixMilli()
|
|
|
|
if r.evalDelay.IsPositive() {
|
|
start = start - r.evalDelay.Milliseconds()
|
|
end = end - r.evalDelay.Milliseconds()
|
|
}
|
|
// round to minute otherwise we could potentially miss data
|
|
start = start - (start % (60 * 1000))
|
|
end = end - (end % (60 * 1000))
|
|
|
|
return time.UnixMilli(start), time.UnixMilli(end)
|
|
}
|
|
|
|
func (r *BaseRule) SetLastError(err error) {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
r.lastError = err
|
|
}
|
|
|
|
func (r *BaseRule) LastError() error {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
return r.lastError
|
|
}
|
|
|
|
func (r *BaseRule) SetHealth(health ruletypes.RuleHealth) {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
r.health = health
|
|
}
|
|
|
|
func (r *BaseRule) Health() ruletypes.RuleHealth {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
return r.health
|
|
}
|
|
|
|
func (r *BaseRule) SetEvaluationDuration(dur time.Duration) {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
r.evaluationDuration = dur
|
|
}
|
|
|
|
func (r *BaseRule) GetEvaluationDuration() time.Duration {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
return r.evaluationDuration
|
|
}
|
|
|
|
func (r *BaseRule) SetEvaluationTimestamp(ts time.Time) {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
r.evaluationTimestamp = ts
|
|
}
|
|
|
|
func (r *BaseRule) GetEvaluationTimestamp() time.Time {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
return r.evaluationTimestamp
|
|
}
|
|
|
|
func (r *BaseRule) State() ruletypes.AlertState {
|
|
maxState := ruletypes.StateInactive
|
|
for _, a := range r.Active {
|
|
if a.State.Severity() > maxState.Severity() {
|
|
maxState = a.State
|
|
}
|
|
}
|
|
return maxState
|
|
}
|
|
|
|
func (r *BaseRule) ActiveAlerts() []*ruletypes.Alert {
|
|
var res []*ruletypes.Alert
|
|
for _, a := range r.currentAlerts() {
|
|
if a.ResolvedAt.IsZero() {
|
|
res = append(res, a)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// SendAlerts hands the rule's alerts that are due to notifyFunc, addressed to
|
|
// the rule's own org: its alertmanager holds the channels and route policies
|
|
// the rule's thresholds name.
|
|
func (r *BaseRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) {
|
|
alerts := []*ruletypes.Alert{}
|
|
r.ForEachActiveAlert(func(alert *ruletypes.Alert) {
|
|
if alert.NeedsSending(ts, resendDelay) {
|
|
alert.LastSentAt = ts
|
|
delta := resendDelay
|
|
if interval > resendDelay {
|
|
delta = interval
|
|
}
|
|
alert.ValidUntil = ts.Add(4 * delta)
|
|
anew := *alert
|
|
alerts = append(alerts, &anew)
|
|
}
|
|
})
|
|
notifyFunc(ctx, r.orgID.StringValue(), alerts...)
|
|
}
|
|
|
|
// withTenant scopes ctx to the rule's org when req reads event.log or
|
|
// event.span, whose builders bind `org = ?`. The tenant is the org's name, the
|
|
// slug its rows carry in `org`. A metrics-only request needs none.
|
|
func (r *BaseRule) withTenant(ctx context.Context, req *qbtypes.QueryRangeRequest) (context.Context, error) {
|
|
if !readsEvents(req) {
|
|
return ctx, nil
|
|
}
|
|
var name string
|
|
err := r.sqlstore.BunDB().NewSelect().
|
|
Table("organizations").
|
|
Column("name").
|
|
Where("id = ?", r.orgID.StringValue()).
|
|
Scan(ctx, &name)
|
|
if err != nil {
|
|
return ctx, errors.Wrapf(err, errors.TypeNotFound, errors.CodeNotFound, "rule %s: no name for org %s to scope its reads", r.id, r.orgID.StringValue())
|
|
}
|
|
return authtypes.NewContextWithTenant(ctx, name), nil
|
|
}
|
|
|
|
// readsEvents reports whether req has a logs or traces builder query, or a
|
|
// trace operator — the queries that read event.log and event.span.
|
|
func readsEvents(req *qbtypes.QueryRangeRequest) bool {
|
|
if req == nil {
|
|
return false
|
|
}
|
|
for _, q := range req.CompositeQuery.Queries {
|
|
if q.Type == qbtypes.QueryTypeTraceOperator {
|
|
return true
|
|
}
|
|
switch q.Spec.(type) {
|
|
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
|
|
r.mtx.Lock()
|
|
defer r.mtx.Unlock()
|
|
|
|
for _, a := range r.Active {
|
|
f(a)
|
|
}
|
|
}
|
|
|
|
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
|
|
if r.ruleStateHistoryModule == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := r.ruleStateHistoryModule.RecordRuleStateHistory(ctx, r.ID(), r.handledRestart, itemsToAdd); err != nil {
|
|
r.logger.ErrorContext(ctx, "error while recording rule state history", errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
|
|
return err
|
|
}
|
|
r.handledRestart = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// ShouldSkipNewGroups returns true if new group filtering should be applied
|
|
func (r *BaseRule) ShouldSkipNewGroups() bool {
|
|
return r.newGroupEvalDelay.IsPositive()
|
|
}
|
|
|
|
// isFilterNewSeriesSupported checks if the query is supported for new series filtering
|
|
func (r *BaseRule) isFilterNewSeriesSupported() bool {
|
|
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
|
|
for _, query := range r.ruleCondition.CompositeQuery.Queries {
|
|
if query.Type != qbtypes.QueryTypeBuilder {
|
|
continue
|
|
}
|
|
switch query.Spec.(type) {
|
|
// query spec is for Logs or Traces, return with blank metric names and group by fields
|
|
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// extractMetricAndGroupBys extracts metric names and groupBy keys from the rule's query.
|
|
// Returns a map where key is the metric name and value is the list of groupBy keys associated with it.
|
|
// TODO: implement caching for query parsing results to avoid re-parsing the query + cache invalidation
|
|
func (r *BaseRule) extractMetricAndGroupBys(ctx context.Context) (map[string][]string, error) {
|
|
metricToGroupedFields := make(map[string][]string)
|
|
|
|
// check to avoid processing the query for Logs and Traces
|
|
// as excluding new series is not supported for Logs and Traces for now
|
|
if !r.isFilterNewSeriesSupported() {
|
|
return metricToGroupedFields, nil
|
|
}
|
|
|
|
results, err := r.queryParser.AnalyzeQueryEnvelopes(ctx, r.ruleCondition.CompositeQuery.Queries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// temp map to avoid duplicates group by fields for the same metric
|
|
// map[metricName]map[groupKey]struct{}
|
|
tempMap := make(map[string]map[string]struct{})
|
|
|
|
// Aggregate results from all queries
|
|
for _, result := range results {
|
|
if len(result.MetricNames) == 0 {
|
|
continue
|
|
}
|
|
// Collect unique groupBy columns for this query result
|
|
uniqueGroups := make(map[string]struct{})
|
|
for _, col := range result.GroupByColumns {
|
|
uniqueGroups[col.GroupName()] = struct{}{}
|
|
}
|
|
// walk through the metric names and group by fields for this query result and add them to the temp map
|
|
for _, metricName := range result.MetricNames {
|
|
if _, ok := tempMap[metricName]; !ok {
|
|
tempMap[metricName] = make(map[string]struct{})
|
|
}
|
|
for groupKey := range uniqueGroups {
|
|
tempMap[metricName][groupKey] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to final map
|
|
for metricName, groups := range tempMap {
|
|
for groupKey := range groups {
|
|
metricToGroupedFields[metricName] = append(metricToGroupedFields[metricName], groupKey)
|
|
}
|
|
}
|
|
|
|
return metricToGroupedFields, nil
|
|
}
|
|
|
|
// 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 []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
|
|
// Extract metric names and groupBy keys
|
|
metricToGroupedFields, err := r.extractMetricAndGroupBys(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(metricToGroupedFields) == 0 {
|
|
// No metrics or groupBy keys, nothing to filter (non-ideal case, return all series)
|
|
return series, nil
|
|
}
|
|
|
|
// Build lookup keys from series which will be used to query metadata from Datastore
|
|
lookupKeys := make([]telemetrytypes.MetricMetadataLookupKey, 0)
|
|
seriesIdxToLookupKeys := make(map[int][]telemetrytypes.MetricMetadataLookupKey) // series index -> lookup keys
|
|
|
|
for i := 0; i < len(series); i++ {
|
|
|
|
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 := valueForKey(groupByKey); ok {
|
|
lookupKey := telemetrytypes.MetricMetadataLookupKey{
|
|
MetricName: metricName,
|
|
AttributeName: groupByKey,
|
|
AttributeValue: attrValue,
|
|
}
|
|
lookupKeys = append(lookupKeys, lookupKey)
|
|
seriesKeys = append(seriesKeys, lookupKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(seriesKeys) > 0 {
|
|
seriesIdxToLookupKeys[i] = seriesKeys
|
|
}
|
|
}
|
|
|
|
if len(lookupKeys) == 0 {
|
|
// No lookup keys to query, return all series
|
|
// this can happen when the series has no labels at all
|
|
// in that case, we include all series as we don't know if it is new or old series
|
|
return series, nil
|
|
}
|
|
|
|
// unique lookup keys
|
|
uniqueLookupKeysMap := make(map[telemetrytypes.MetricMetadataLookupKey]struct{})
|
|
uniqueLookupKeys := make([]telemetrytypes.MetricMetadataLookupKey, 0)
|
|
for _, key := range lookupKeys {
|
|
if _, ok := uniqueLookupKeysMap[key]; !ok {
|
|
uniqueLookupKeysMap[key] = struct{}{}
|
|
uniqueLookupKeys = append(uniqueLookupKeys, key)
|
|
}
|
|
}
|
|
// Query metadata for first_seen timestamps
|
|
firstSeenMap, err := r.metadataStore.GetFirstSeenFromMetricMetadata(ctx, uniqueLookupKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter series based on first_seen + delay
|
|
filteredSeries := make([]*qbtypes.TimeSeries, 0, len(series))
|
|
evalTimeMs := ts.UnixMilli()
|
|
newGroupEvalDelayMs := r.newGroupEvalDelay.Milliseconds()
|
|
|
|
for i := 0; i < len(series); i++ {
|
|
seriesKeys, ok := seriesIdxToLookupKeys[i]
|
|
if !ok {
|
|
// No matching labels used in groupBy from this series, include it
|
|
// as we can't decide if it is new or old series
|
|
filteredSeries = append(filteredSeries, series[i])
|
|
continue
|
|
}
|
|
|
|
// Find the maximum first_seen across all groupBy attributes for this series
|
|
// if the latest is old enough we're good, if latest is new we need to skip it
|
|
maxFirstSeen := int64(0)
|
|
// metadataFound tracks if we have metadata for any of the lookup keys
|
|
metadataFound := false
|
|
|
|
for _, lookupKey := range seriesKeys {
|
|
if firstSeen, exists := firstSeenMap[lookupKey]; exists {
|
|
metadataFound = true
|
|
if firstSeen > maxFirstSeen {
|
|
maxFirstSeen = firstSeen
|
|
}
|
|
}
|
|
}
|
|
|
|
// if we don't have metadata for any of the lookup keys, we can't decide if it is new or old series
|
|
// in that case, we include it
|
|
if !metadataFound {
|
|
filteredSeries = append(filteredSeries, series[i])
|
|
continue
|
|
}
|
|
|
|
// 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",
|
|
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
|
|
}
|
|
|
|
// Old enough, include this series
|
|
filteredSeries = append(filteredSeries, series[i])
|
|
}
|
|
|
|
skippedCount := len(series) - len(filteredSeries)
|
|
if skippedCount > 0 {
|
|
r.logger.InfoContext(
|
|
ctx, "filtered new series",
|
|
slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)),
|
|
slog.Int64("eval.delay_ms", newGroupEvalDelayMs),
|
|
)
|
|
}
|
|
|
|
return filteredSeries, nil
|
|
}
|
|
|
|
// HandleMissingDataAlert handles missing data alert logic by tracking the last timestamp
|
|
// with data points and checking if a missing data alert should be sent based on the
|
|
// [ruletypes.RuleCondition.AlertOnAbsent] and [ruletypes.RuleCondition.AbsentFor] conditions.
|
|
//
|
|
// Returns a pointer to the missing data alert if conditions are met, nil otherwise.
|
|
func (r *BaseRule) HandleMissingDataAlert(ctx context.Context, ts time.Time, hasData bool) *ruletypes.Sample {
|
|
// Track the last timestamp with data points for missing data alerts
|
|
if hasData {
|
|
r.lastTimestampWithDatapoints = ts
|
|
}
|
|
|
|
if !r.ruleCondition.AlertOnAbsent || ts.Before(r.lastTimestampWithDatapoints.Add(time.Duration(r.ruleCondition.AbsentFor)*time.Minute)) {
|
|
return nil
|
|
}
|
|
|
|
r.logger.InfoContext(ctx, "no data found for rule condition")
|
|
lbls := ruletypes.NewBuilder()
|
|
if !r.lastTimestampWithDatapoints.IsZero() {
|
|
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(ruletypes.AlertTimeFormat))
|
|
}
|
|
return &ruletypes.Sample{Metric: lbls.Labels(), IsMissing: true}
|
|
}
|