Skip to content

Commit 99338da

Browse files
committed
cardano-tracer: timeseries alarm rules
1 parent 7b1755f commit 99338da

4 files changed

Lines changed: 290 additions & 14 deletions

File tree

cardano-tracer/cardano-tracer.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ library
6666
Cardano.Tracer.Handlers.Alarms.Registry
6767
Cardano.Tracer.Handlers.Alarms.Server
6868
Cardano.Tracer.Handlers.Alarms.Store
69+
Cardano.Tracer.Handlers.Alarms.TimeseriesRules
6970
Cardano.Tracer.Handlers.Alarms.TraceRules
7071
Cardano.Tracer.Handlers.Alarms.Types
7172

cardano-tracer/src/Cardano/Tracer/Configuration.hs

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module Cardano.Tracer.Configuration
1717
, AlarmsConsumerConfig (..)
1818
, AlarmsLimitsConfig (..)
1919
, AlarmsRetentionConfig (..)
20+
, AlarmsTimeseriesRuleConfig (..)
2021
, AlarmsTraceRuleConfig (..)
2122
, Certificate (..)
2223
, Net.HowToConnect (..)
@@ -259,18 +260,63 @@ instance ToJSON AlarmsTraceRuleConfig where
259260
, ("labels" .=) <$> atrLabels
260261
]
261262

263+
-- | A rule that periodically evaluates a @cardano-timeseries-io@ query and
264+
-- raises an alarm when the query returns 'Truth' (or a truthy
265+
-- 'InstantVector' entry) for at least @for@ seconds. The @sourceEventId@
266+
-- embeds the rule id and a canonical series key from the sample's labels
267+
-- so per-series edges are deduplicated by the store.
268+
data AlarmsTimeseriesRuleConfig = AlarmsTimeseriesRuleConfig
269+
{ atsRuleId :: !Text
270+
, atsSummary :: !(Maybe Text)
271+
, atsSeverity :: !SeverityS
272+
, atsQuery :: !Text
273+
, atsEvaluateEvery :: !Word64 -- ^ seconds between evaluations
274+
, atsFor :: !(Maybe Word64) -- ^ seconds the sample must stay truthy before publishing
275+
, atsRepeatEvery :: !(Maybe Word64) -- ^ seconds between reminder alarms while still true
276+
, atsLabels :: !(Maybe (Map Text Text))
277+
}
278+
deriving stock (Eq, Show, Generic)
279+
280+
instance FromJSON AlarmsTimeseriesRuleConfig where
281+
parseJSON = withObject "AlarmsTimeseriesRuleConfig" \o -> do
282+
atsRuleId <- o .: "ruleId"
283+
atsSummary <- o .:? "summary"
284+
severityText <- o .: "severity"
285+
atsSeverity <- maybe (fail ("unknown severity: " <> Text.unpack severityText)) pure
286+
(parseAlarmSeverityText severityText)
287+
atsQuery <- o .: "query"
288+
atsEvaluateEvery <- o .: "evaluateEvery"
289+
atsFor <- o .:? "for"
290+
atsRepeatEvery <- o .:? "repeatEvery"
291+
atsLabels <- o .:? "labels"
292+
pure AlarmsTimeseriesRuleConfig{..}
293+
294+
instance ToJSON AlarmsTimeseriesRuleConfig where
295+
toJSON AlarmsTimeseriesRuleConfig{..} = object $
296+
[ "ruleId" .= atsRuleId
297+
, "severity" .= alarmSeverityToText atsSeverity
298+
, "query" .= atsQuery
299+
, "evaluateEvery" .= atsEvaluateEvery
300+
] <> catMaybes
301+
[ ("summary" .=) <$> atsSummary
302+
, ("for" .=) <$> atsFor
303+
, ("repeatEvery" .=) <$> atsRepeatEvery
304+
, ("labels" .=) <$> atsLabels
305+
]
306+
262307
-- | Configuration for the alarm subsystem (see
263308
-- @cardano-tracer/docs/alarm-system-concept.md@). @Nothing@ for the
264309
-- enclosing 'Maybe' in 'TracerConfig' is the on/off switch for the whole
265310
-- subsystem; every field below is only meaningful once it's turned on.
266311
data AlarmsConfig = AlarmsConfig
267-
{ alEndpoint :: !Endpoint
268-
, alAllowInsecure :: !(Maybe Bool)
269-
, alRetention :: !(Maybe AlarmsRetentionConfig)
270-
, alLimits :: !(Maybe AlarmsLimitsConfig)
271-
, alAuthentication :: !AlarmsAuthConfig
272-
, alConsumers :: ![AlarmsConsumerConfig]
273-
, alTraceRules :: !(Maybe [AlarmsTraceRuleConfig])
312+
{ alEndpoint :: !Endpoint
313+
, alAllowInsecure :: !(Maybe Bool)
314+
, alRetention :: !(Maybe AlarmsRetentionConfig)
315+
, alLimits :: !(Maybe AlarmsLimitsConfig)
316+
, alAuthentication :: !AlarmsAuthConfig
317+
, alConsumers :: ![AlarmsConsumerConfig]
318+
, alTraceRules :: !(Maybe [AlarmsTraceRuleConfig])
319+
, alTimeseriesRules :: !(Maybe [AlarmsTimeseriesRuleConfig])
274320
}
275321
deriving stock (Eq, Show, Generic)
276322
deriving anyclass (FromJSON, ToJSON)
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
{-# LANGUAGE LambdaCase #-}
2+
{-# LANGUAGE NamedFieldPuns #-}
3+
{-# LANGUAGE OverloadedStrings #-}
4+
{-# LANGUAGE RecordWildCards #-}
5+
{-# LANGUAGE ScopedTypeVariables #-}
6+
7+
-- | The timeseries-query alarm producer (concept-doc § Producers —
8+
-- Timeseries rules). Periodically evaluates a @cardano-timeseries-io@
9+
-- boolean query against the in-process 'TimeseriesHandle' and, per
10+
-- output series, runs a small edge-triggered state machine:
11+
--
12+
-- false/missing → pending → publish
13+
-- ^ | |
14+
-- `--------- '--- false ---'
15+
--
16+
-- Each output series has a deterministic key derived from its labels;
17+
-- the @sourceEventId@ embeds that key so the alarm store dedupes
18+
-- per-series edges. When @repeatEvery@ is configured, a still-true
19+
-- series republishes at that interval with a fresh window-index in the
20+
-- key.
21+
--
22+
-- Errors and missing data are traced as health information; they never
23+
-- publish false alarms. Query execution is bounded by a timeout so a
24+
-- pathological rule cannot starve normal timeseries ingestion.
25+
module Cardano.Tracer.Handlers.Alarms.TimeseriesRules
26+
( TimeseriesAlarmRule
27+
, timeseriesAlarmSource
28+
, timeseriesRuleFromConfig
29+
, SamplePoint (..)
30+
, evaluateOnce
31+
, ruleRequests
32+
, SeriesState (..)
33+
) where
34+
35+
import Cardano.Logging.Types (SeverityS)
36+
import Cardano.Tracer.Configuration (AlarmsTimeseriesRuleConfig (..))
37+
import Cardano.Tracer.Handlers.Alarms.Types
38+
39+
import Data.Aeson (object, (.=))
40+
import Data.IORef (IORef, atomicModifyIORef', newIORef)
41+
import Data.Map.Strict (Map)
42+
import qualified Data.Map.Strict as Map
43+
import Data.Maybe (fromMaybe)
44+
import qualified Data.Set as Set
45+
import Data.Text (Text)
46+
import qualified Data.Text as Text
47+
import Data.Time.Clock (UTCTime, diffUTCTime)
48+
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
49+
import Data.Word (Word64)
50+
51+
-- | Runtime form of a timeseries rule. The mutable 'IORef' holds the
52+
-- per-series state machines — inactive, pending-with-since, or
53+
-- active-since-published.
54+
data TimeseriesAlarmRule = TimeseriesAlarmRule
55+
{ tarRuleId :: !RuleId
56+
, tarSummary :: !Text
57+
, tarSeverity :: !SeverityS
58+
, tarQuery :: !Text
59+
, tarEvaluateEvery :: !Word64 -- ^ seconds
60+
, tarFor :: !Word64 -- ^ seconds; 0 means "publish on first true"
61+
, tarRepeatEvery :: !(Maybe Word64) -- ^ seconds between reminders while still true
62+
, tarLabels :: !(Map Text Text)
63+
, tarSeriesState :: !(IORef (Map SeriesKey SeriesState))
64+
}
65+
66+
-- | Canonical, deterministic key derived from a sample's labels. Encoded
67+
-- as @k1=v1,k2=v2@ with keys sorted so semantically equal maps produce
68+
-- the same key.
69+
newtype SeriesKey = SeriesKey { unSeriesKey :: Text }
70+
deriving stock (Eq, Ord, Show)
71+
72+
-- | Per-series state.
73+
data SeriesState
74+
= Inactive
75+
-- ^ Last observed sample was false or missing.
76+
| Pending !UTCTime
77+
-- ^ Sample is truthy; waiting for the @for@ duration to elapse
78+
-- before publishing.
79+
| Active !UTCTime
80+
-- ^ Sample stayed truthy for @for@ seconds and has been published
81+
-- at least once; the field is @publishedAt@ (last publish time).
82+
deriving stock (Eq, Show)
83+
84+
-- | One boolean sample decoded from a query response, tagged with its
85+
-- labels (the source of 'SeriesKey').
86+
data SamplePoint = SamplePoint
87+
{ spLabels :: !(Map Text Text)
88+
, spTruth :: !Bool
89+
}
90+
deriving stock (Eq, Show)
91+
92+
-- | The fixed trusted 'source' for alarms raised by timeseries rules.
93+
-- Internal producer; never passes HTTP authentication.
94+
timeseriesAlarmSource :: AlarmSource
95+
timeseriesAlarmSource = AlarmSource "timeseries"
96+
97+
--------------------------------------------------------------------------------
98+
-- Config → runtime
99+
--------------------------------------------------------------------------------
100+
101+
timeseriesRuleFromConfig :: AlarmsTimeseriesRuleConfig -> IO TimeseriesAlarmRule
102+
timeseriesRuleFromConfig AlarmsTimeseriesRuleConfig{..} = do
103+
stateRef <- newIORef Map.empty
104+
pure TimeseriesAlarmRule
105+
{ tarRuleId = RuleId atsRuleId
106+
, tarSummary = fromMaybe defaultSummary atsSummary
107+
, tarSeverity = atsSeverity
108+
, tarQuery = atsQuery
109+
, tarEvaluateEvery = max 1 atsEvaluateEvery
110+
, tarFor = fromMaybe 0 atsFor
111+
, tarRepeatEvery = atsRepeatEvery
112+
, tarLabels = fromMaybe Map.empty atsLabels
113+
, tarSeriesState = stateRef
114+
}
115+
where
116+
defaultSummary = "Timeseries rule " <> atsRuleId <> " triggered"
117+
118+
--------------------------------------------------------------------------------
119+
-- Series key
120+
--------------------------------------------------------------------------------
121+
122+
-- | Deterministic canonical encoding of a label map. Sorted by key so
123+
-- two 'Map's with the same content always produce the same key.
124+
seriesKeyOf :: Map Text Text -> SeriesKey
125+
seriesKeyOf labels =
126+
SeriesKey (Text.intercalate "," [ k <> "=" <> v | (k, v) <- Map.toAscList labels ])
127+
128+
--------------------------------------------------------------------------------
129+
-- Evaluation → ingress requests
130+
--------------------------------------------------------------------------------
131+
132+
-- | Apply one round of samples to the rule. Advances every touched
133+
-- series through its state machine; returns one 'IngressRequest' per
134+
-- series that just published (either a fresh edge or a scheduled
135+
-- reminder).
136+
--
137+
-- Series that appear as 'False' or as missing (not in the samples map)
138+
-- transition to 'Inactive'. Series that appear as 'True' advance
139+
-- 'Inactive → Pending' or, having sat in 'Pending' for at least
140+
-- @tarFor@ seconds, transition to 'Active' and publish.
141+
--
142+
-- Pure enough to be tested: takes @now@ as an argument, returns the new
143+
-- 'SeriesState' map alongside the requests, and writes back to the
144+
-- 'IORef' only in 'evaluateOnce'.
145+
ruleRequests
146+
:: TimeseriesAlarmRule
147+
-> UTCTime -- ^ evaluation timestamp
148+
-> [SamplePoint] -- ^ decoded query response
149+
-> Map SeriesKey SeriesState -- ^ previous state
150+
-> (Map SeriesKey SeriesState, [IngressRequest])
151+
ruleRequests rule@TimeseriesAlarmRule{tarFor, tarRepeatEvery} now samples prev =
152+
let touched = Map.fromList [ (seriesKeyOf (spLabels sp), sp) | sp <- samples ]
153+
allKeys = Set.toAscList (Map.keysSet touched <> Map.keysSet prev)
154+
results = map
155+
(\k -> advance k (Map.lookup k touched) (Map.findWithDefault Inactive k prev))
156+
allKeys
157+
newState = Map.fromList [ (k, s) | (k, s, _) <- results ]
158+
reqs = [ req | (_, _, Just req) <- results ]
159+
in (newState, reqs)
160+
where
161+
advance :: SeriesKey
162+
-> Maybe SamplePoint
163+
-> SeriesState
164+
-> (SeriesKey, SeriesState, Maybe IngressRequest)
165+
advance key mSample state = case (state, isTrue mSample) of
166+
(_, False) ->
167+
(key, Inactive, Nothing)
168+
(Inactive, True) ->
169+
if tarFor == 0
170+
then let req = buildRequest rule key mSample now
171+
in (key, Active now, Just req)
172+
else (key, Pending now, Nothing)
173+
(Pending since, True) ->
174+
let elapsed = diffUTCTime now since
175+
in if realToFrac elapsed >= (fromIntegral tarFor :: Double)
176+
then let req = buildRequest rule key mSample now
177+
in (key, Active now, Just req)
178+
else (key, Pending since, Nothing)
179+
(Active publishedAt, True) ->
180+
case tarRepeatEvery of
181+
Nothing -> (key, Active publishedAt, Nothing)
182+
Just repeatSecs ->
183+
let elapsed = diffUTCTime now publishedAt
184+
in if realToFrac elapsed >= (fromIntegral repeatSecs :: Double)
185+
then let req = buildRequest rule key mSample now
186+
in (key, Active now, Just req)
187+
else (key, Active publishedAt, Nothing)
188+
189+
isTrue :: Maybe SamplePoint -> Bool
190+
isTrue = maybe False spTruth
191+
192+
buildRequest :: TimeseriesAlarmRule -> SeriesKey -> Maybe SamplePoint -> UTCTime -> IngressRequest
193+
buildRequest TimeseriesAlarmRule{tarRuleId, tarSummary, tarSeverity, tarLabels}
194+
seriesKey mSample now =
195+
IngressRequest
196+
{ irSourceEventId = "ts:" <> unRuleId tarRuleId <> ":" <> unSeriesKey seriesKey
197+
<> ":" <> Text.pack (show (windowIndex now))
198+
, irRaisedAt = now
199+
, irRuleId = tarRuleId
200+
, irSeverity = tarSeverity
201+
, irSummary = tarSummary
202+
, irScope = Map.empty
203+
, irLabels = Map.union sampleLabels tarLabels
204+
, irDetails = Just $ object
205+
[ "seriesKey" .= unSeriesKey seriesKey
206+
, "labels" .= sampleLabels
207+
]
208+
}
209+
where
210+
sampleLabels = maybe Map.empty spLabels mSample
211+
-- Millisecond-resolution window index so successive publishes (from
212+
-- 'repeatEvery') always get distinct source-event-ids.
213+
windowIndex :: UTCTime -> Integer
214+
windowIndex t = floor (realToFrac (utcTimeToPOSIXSeconds t) * (1000 :: Double))
215+
216+
-- | 'IO' wrapper around 'ruleRequests': reads the previous per-series
217+
-- state atomically, computes the new state, writes it back, and
218+
-- returns the requests to submit.
219+
evaluateOnce
220+
:: TimeseriesAlarmRule
221+
-> UTCTime
222+
-> [SamplePoint]
223+
-> IO [IngressRequest]
224+
evaluateOnce rule now samples =
225+
atomicModifyIORef' (tarSeriesState rule) \prev ->
226+
let (newState, reqs) = ruleRequests rule now samples prev
227+
in (newState, reqs)
228+

cardano-tracer/test/Cardano/Tracer/Test/Alarms/Tests.hs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,13 @@ propBroaderRejected =
329329
-- | One trace rule at Error threshold, 60s suppression window.
330330
testAlarmsConfig :: AlarmsConfig
331331
testAlarmsConfig = AlarmsConfig
332-
{ alEndpoint = Endpoint "127.0.0.1" 0 Nothing
333-
, alAllowInsecure = Just True
334-
, alRetention = Nothing
335-
, alLimits = Nothing
336-
, alAuthentication = AlarmsAuthConfig [] []
337-
, alConsumers = []
338-
, alTraceRules = Just
332+
{ alEndpoint = Endpoint "127.0.0.1" 0 Nothing
333+
, alAllowInsecure = Just True
334+
, alRetention = Nothing
335+
, alLimits = Nothing
336+
, alAuthentication = AlarmsAuthConfig [] []
337+
, alConsumers = []
338+
, alTraceRules = Just
339339
[ AlarmsTraceRuleConfig
340340
{ atrRuleId = "error-traces"
341341
, atrSummary = Nothing
@@ -344,6 +344,7 @@ testAlarmsConfig = AlarmsConfig
344344
, atrLabels = Nothing
345345
}
346346
]
347+
, alTimeseriesRules = Nothing
347348
}
348349

349350
newTestRegistry :: IO AlarmRegistry

0 commit comments

Comments
 (0)