Skip to content

Commit 1bb9e7e

Browse files
committed
cardano-tracer: rework Prometheus exposition for OpenMetrics compliance
1 parent 090b005 commit 1bb9e7e

File tree

10 files changed

+189
-96
lines changed

10 files changed

+189
-96
lines changed

cardano-tracer/bench/cardano-tracer-bench.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ main = do
7575
, teReforwardTraceObjects = \_-> pure ()
7676
, teRegistry = handleRegistry
7777
, teStateDir = Nothing
78+
, teMetricsHelp = []
7879
}
7980

8081
tracerEnvRTView :: TracerEnvRTView
@@ -148,6 +149,7 @@ main = do
148149
, rotation = Nothing
149150
, verbosity = Nothing
150151
, metricsComp = Nothing
152+
, metricsHelp = Nothing
151153
, hasForwarding = Nothing
152154
, resourceFreq = Nothing
153155
}

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module Cardano.Tracer.Configuration
1111
( Address (..)
1212
, Endpoint (..)
1313
, setEndpoint
14+
, FileOrMap (..)
1415
, LogFormat (..)
1516
, LogMode (..)
1617
, LoggingParams (..)
@@ -24,7 +25,7 @@ module Cardano.Tracer.Configuration
2425
import qualified Cardano.Logging.Types as Log
2526

2627
import Control.Applicative ((<|>))
27-
import Data.Aeson (FromJSON (..), ToJSON, withObject, (.:))
28+
import Data.Aeson (FromJSON (..), ToJSON (..), withObject, (.:))
2829
import Data.Fixed (Pico)
2930
import Data.Function ((&))
3031
import Data.Functor ((<&>))
@@ -39,10 +40,9 @@ import Data.Text (Text)
3940
import Data.Word (Word16, Word32, Word64)
4041
import Data.Yaml (decodeFileEither)
4142
import GHC.Generics (Generic)
43+
import Network.Wai.Handler.Warp (HostPreference, Port, Settings, setHost, setPort)
4244
import System.Exit (die)
4345

44-
import Network.Wai.Handler.Warp (HostPreference, Port, Settings, setHost, setPort)
45-
4646
-- | Only local socket is supported, to avoid unauthorized connections.
4747
newtype Address = LocalSocket FilePath
4848
deriving stock (Eq, Generic, Show)
@@ -119,6 +119,17 @@ data Verbosity
119119
deriving stock (Eq, Generic, Show)
120120
deriving anyclass (FromJSON, ToJSON)
121121

122+
newtype FileOrMap = FOM (Either FilePath (Map Text Text))
123+
deriving stock (Eq, Show)
124+
125+
instance ToJSON FileOrMap where
126+
toJSON (FOM fom) = either toJSON toJSON fom
127+
toEncoding (FOM fom) = either toEncoding toEncoding fom
128+
129+
instance FromJSON FileOrMap where
130+
parseJSON v =
131+
(FOM . Left <$> parseJSON v) <|> (FOM . Right <$> parseJSON v)
132+
122133
-- | Tracer configuration.
123134
data TracerConfig = TracerConfig
124135
{ networkMagic :: !Word32 -- ^ Network magic from genesis the node is launched with.
@@ -138,6 +149,7 @@ data TracerConfig = TracerConfig
138149
, rotation :: !(Maybe RotationParams) -- ^ Rotation parameters.
139150
, verbosity :: !(Maybe Verbosity) -- ^ Verbosity of the tracer itself.
140151
, metricsComp :: !(Maybe (Map Text Text)) -- ^ Metrics compatibility map from metrics name to metrics name
152+
, metricsHelp :: !(Maybe FileOrMap) -- ^ JSON file or object containing a key-value map "metric name -> help text" for Prometheus "# HELP " annotations
141153
, resourceFreq :: !(Maybe Int) -- ^ Frequency (1/millisecond) for gathering resource data.
142154
}
143155
deriving stock (Eq, Show, Generic)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import Cardano.Tracer.MetaTrace
1717
import Cardano.Tracer.Types
1818

1919
import Control.Concurrent.Extra (Lock)
20+
import Data.Text (Text)
21+
import Data.Text.Lazy.Builder (Builder)
22+
2023

2124
-- | Environment for all functions.
2225
data TracerEnv = TracerEnv
@@ -32,6 +35,7 @@ data TracerEnv = TracerEnv
3235
, teReforwardTraceObjects :: !([TraceObject] -> IO ())
3336
, teRegistry :: !HandleRegistry
3437
, teStateDir :: !(Maybe FilePath)
38+
, teMetricsHelp :: !([(Text, Builder)])
3539
}
3640

3741
#if RTVIEW

cardano-tracer/src/Cardano/Tracer/Handlers/Metrics/Prometheus.hs

Lines changed: 115 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,32 @@ module Cardano.Tracer.Handlers.Metrics.Prometheus
88

99
import Cardano.Tracer.Configuration
1010
import Cardano.Tracer.Environment
11+
import Cardano.Tracer.Handlers.Metrics.Utils
1112
import Cardano.Tracer.MetaTrace
1213

1314
import Prelude hiding (head)
1415

16+
import qualified Data.ByteString as ByteString
1517
import Data.ByteString.Builder (stringUtf8)
18+
import Data.Char
1619
import Data.Functor ((<&>))
20+
import qualified Data.HashMap.Strict as HM
21+
import Data.List (find)
1722
import Data.Map.Strict (Map)
23+
import qualified Data.Map.Strict as M
1824
import Data.Text (Text)
25+
import qualified Data.Text as T
26+
import qualified Data.Text.Lazy as TL
27+
import Data.Text.Lazy.Builder (Builder)
28+
import qualified Data.Text.Lazy.Builder as TB
29+
import qualified Data.Text.Lazy.Builder.Int as TB
30+
import qualified Data.Text.Lazy.Encoding as TL
1931
import Network.HTTP.Types
2032
import Network.Wai hiding (responseHeaders)
21-
import Network.Wai.Handler.Warp (runSettings, defaultSettings)
33+
import Network.Wai.Handler.Warp (defaultSettings, runSettings)
34+
import qualified System.Metrics as EKG
2235
import System.Metrics (Sample, Value (..), sampleAll)
2336
import System.Time.Extra (sleep)
24-
import qualified Cardano.Tracer.Handlers.Metrics.Utils as Utils
25-
import qualified Data.ByteString as ByteString
26-
import qualified Data.HashMap.Strict as HM
27-
import qualified Data.Map.Strict as Map
28-
import qualified Data.Text as T
29-
import qualified Data.Text.Lazy as Lazy.Text
30-
import qualified Data.Text.Lazy.Encoding as Lazy.Text
31-
import qualified System.Metrics as EKG
3237

3338
-- | Runs simple HTTP server that listens host and port and returns
3439
-- the list of currently connected nodes in such a format:
@@ -52,7 +57,7 @@ import qualified System.Metrics as EKG
5257
runPrometheusServer
5358
:: TracerEnv
5459
-> Endpoint
55-
-> IO Utils.RouteDictionary
60+
-> IO RouteDictionary
5661
-> IO ()
5762
runPrometheusServer tracerEnv endpoint computeRoutes_autoUpdate = do
5863
-- Pause to prevent collision between "Listening"-notifications from servers.
@@ -64,90 +69,126 @@ runPrometheusServer tracerEnv endpoint computeRoutes_autoUpdate = do
6469
{ ttPrometheusEndpoint = endpoint
6570
}
6671
runSettings (setEndpoint endpoint defaultSettings) do
67-
renderPrometheus computeRoutes_autoUpdate metricsComp where
72+
renderPrometheus computeRoutes_autoUpdate metricsComp teMetricsHelp where
6873

6974
TracerEnv
7075
{ teTracer
7176
, teConfig = TracerConfig { metricsComp }
77+
, teMetricsHelp
7278
} = tracerEnv
7379

74-
renderPrometheus :: IO Utils.RouteDictionary -> Maybe (Map Text Text) -> Application
75-
renderPrometheus computeRoutes_autoUpdate metricsComp request send = do
76-
routeDictionary :: Utils.RouteDictionary <-
80+
renderPrometheus
81+
:: IO RouteDictionary
82+
-> Maybe (Map Text Text)
83+
-> [(Text, Builder)]
84+
-> Application
85+
renderPrometheus computeRoutes_autoUpdate metricsComp helpTextDict request send = do
86+
routeDictionary :: RouteDictionary <-
7787
computeRoutes_autoUpdate
7888

79-
let header :: RequestHeaders
80-
header = requestHeaders request
81-
82-
let wantsJson :: Bool
83-
wantsJson = all @Maybe ("application/json" `ByteString.isInfixOf`) (lookup hAccept header)
89+
let acceptHeader :: Maybe ByteString.ByteString
90+
acceptHeader = lookup hAccept $ requestHeaders request
8491

85-
let responseHeaders :: ResponseHeaders
86-
responseHeaders = [(hContentType, if wantsJson then "application/json" else "text/html")]
92+
let wantsJson, wantsOpenMetrics :: Bool
93+
wantsJson = all @Maybe ("application/json" `ByteString.isInfixOf`) acceptHeader
94+
wantsOpenMetrics = all @Maybe ("application/openmetrics-text" `ByteString.isInfixOf`) acceptHeader
8795

8896
case pathInfo request of
8997

9098
[] ->
91-
send $ responseLBS status200 responseHeaders if wantsJson
92-
then Utils.renderJson routeDictionary
93-
else Utils.renderListOfConnectedNodes "Prometheus metrics" (Utils.nodeNames routeDictionary)
99+
send $ uncurry (responseLBS status200) $ if wantsJson
100+
then (contentHdrJSON , renderJson routeDictionary)
101+
else (contentHdrUtf8Html, renderListOfConnectedNodes "Prometheus metrics" (nodeNames routeDictionary))
94102

95103
route:_
96-
| Just (store :: EKG.Store, _) <- lookup route (Utils.getRouteDictionary routeDictionary)
97-
-> do metrics <- getMetricsFromNode metricsComp store
98-
send $ responseLBS status200 [(hContentType, "text/plain")] (Lazy.Text.encodeUtf8 (Lazy.Text.fromStrict metrics))
104+
| Just (store :: EKG.Store, _) <- lookup route (getRouteDictionary routeDictionary)
105+
-> do metrics <- getMetricsFromNode metricsComp helpTextDict store
106+
send $ responseBuilder status200
107+
(if wantsOpenMetrics then contentHdrOpenMetrics else contentHdrUtf8Text)
108+
(TL.encodeUtf8Builder metrics)
99109

100-
-- all endings in ekg-wai's asset/ folder
101110
| otherwise
102-
-> send $ responseBuilder status404 [(hContentType, "text/plain")] do
111+
-> send $ responseBuilder status404 contentHdrUtf8Text do
103112
"Not found: "
104113
<> stringUtf8 (show route)
105114

106-
type MetricName = Text
107-
type MetricValue = Text
108-
type MetricsList = [(MetricName, MetricValue)]
115+
type MetricName = Text
109116

110117
getMetricsFromNode
111-
:: Maybe (Map Text Text)
118+
:: Maybe (Map MetricName MetricName)
119+
-> [(Text, Builder)]
112120
-> EKG.Store
113-
-> IO Text
114-
getMetricsFromNode metricsComp ekgStore =
115-
sampleAll ekgStore <&> renderListOfMetrics . getListOfMetrics
116-
where
117-
118-
getListOfMetrics :: Sample -> MetricsList
119-
getListOfMetrics =
120-
metricsCompatibility
121-
. filter (not . T.null . fst)
122-
. map metricsWeNeed
123-
. HM.toList
124-
125-
metricsWeNeed :: (Text, Value) -> (Text, Text)
126-
metricsWeNeed (mName, mValue) =
127-
case mValue of
128-
Counter c -> (mName, T.pack $ show c)
129-
Gauge g -> (mName, T.pack $ show g)
130-
Label l -> (mName, l)
131-
_ -> ("", "") -- 'ekg-forward' doesn't support 'Distribution' yet.
132-
133-
metricsCompatibility :: MetricsList -> MetricsList
134-
metricsCompatibility metricsList =
135-
case metricsComp of
136-
Nothing -> metricsList
137-
Just mmap -> foldl (\ accu p'@(mn,mv) -> case Map.lookup mn mmap of
138-
Nothing -> p' : accu
139-
Just rep -> p' : (rep,mv) : accu)
140-
[]
141-
metricsList
142-
143-
renderListOfMetrics :: MetricsList -> Text
144-
renderListOfMetrics [] = "No metrics were received from this node."
145-
renderListOfMetrics mList = T.intercalate "\n" $
146-
map (\(mName, mValue) -> prepareName mName <> " " <> mValue) mList
147-
148-
prepareName :: Text -> Text
149-
prepareName =
150-
T.filter (`elem` (['0'..'9'] ++ ['a'..'z'] ++ ['A'..'Z'] ++ ['_']))
151-
. T.replace " " "_"
152-
. T.replace "-" "_"
153-
. T.replace "." "_"
121+
-> IO TL.Text
122+
getMetricsFromNode metricsComp helpTextDict ekgStore =
123+
sampleAll ekgStore <&> renderExpositionFromSample metricsComp helpTextDict
124+
125+
renderExpositionFromSample
126+
:: Maybe (Map MetricName MetricName)
127+
-> [(MetricName, Builder)]
128+
-> Sample
129+
-> TL.Text
130+
renderExpositionFromSample renameMap helpTextDict =
131+
TB.toLazyText . (`mappend` buildEOF) . HM.foldlWithKey' buildMetric mempty
132+
where
133+
buildHelpText :: MetricName -> (Builder -> Builder)
134+
buildHelpText name = maybe
135+
(const mempty)
136+
(buildHelp . snd)
137+
(find ((`T.isInfixOf` name) . fst) helpTextDict)
138+
139+
-- implements the metricsComp config option
140+
replaceName :: MetricName -> MetricName
141+
replaceName =
142+
case renameMap of
143+
Nothing -> Prelude.id
144+
Just mmap -> \name -> M.findWithDefault name name mmap
145+
146+
prepareName :: MetricName -> MetricName
147+
prepareName =
148+
T.filter (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c == '_')
149+
. T.replace " " "_"
150+
. T.replace "-" "_"
151+
. T.replace "." "_"
152+
153+
-- the help annotation line
154+
buildHelp :: Builder -> Builder -> Builder
155+
buildHelp h n =
156+
TB.fromText "# HELP " `mappend` (n `mappend` (space `mappend` (h `mappend` newline)))
157+
158+
buildMetric :: TB.Builder -> MetricName -> Value -> TB.Builder
159+
buildMetric acc mName mValue =
160+
acc `mappend` case mValue of
161+
Counter c -> annotate buildCounter `mappend` buildVal space (TB.decimal c)
162+
Gauge g -> annotate buildGauge `mappend` buildVal space (TB.decimal g)
163+
Label l
164+
| Just ('{', _) <- T.uncons l
165+
-> annotate buildInfo `mappend` buildVal mempty (TB.fromText l)
166+
| otherwise
167+
-> helpAnnotation `mappend` buildVal space (TB.fromText l)
168+
_ -> mempty
169+
where
170+
helpAnnotation = buildHelpText mName buildName
171+
172+
-- annotates a metric in the order TYPE, UNIT, HELP
173+
-- TODO: UNIT annotation
174+
annotate annType =
175+
buildTypeAnn annType `mappend` helpAnnotation
176+
177+
-- the name for exposition
178+
buildName = TB.fromText $ prepareName $ replaceName mName
179+
180+
-- the type annotation line
181+
buildTypeAnn t =
182+
TB.fromText "# TYPE " `mappend` (buildName `mappend` (t `mappend` newline))
183+
184+
-- the actual metric line, optional spacing after name, because of labels: 'metric_name{label_value="foo"} 1'
185+
buildVal spacing v =
186+
buildName `mappend` (spacing `mappend` (v `mappend` newline))
187+
188+
buildGauge, buildCounter, buildInfo, buildEOF, newline, space :: Builder
189+
buildGauge = TB.fromText " gauge"
190+
buildCounter = TB.fromText " counter"
191+
buildInfo = TB.fromText " info"
192+
buildEOF = TB.fromText "# EOF\n"
193+
newline = TB.singleton '\n'
194+
space = TB.singleton ' '

cardano-tracer/src/Cardano/Tracer/Handlers/Metrics/Utils.hs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,30 @@
22
{-# LANGUAGE OverloadedStrings #-}
33

44
module Cardano.Tracer.Handlers.Metrics.Utils
5-
( RouteDictionary(..)
6-
, renderListOfConnectedNodes
7-
, renderJson
8-
, nodeNames
9-
, computeRoutes
5+
( module Cardano.Tracer.Handlers.Metrics.Utils
106
) where
117

12-
import qualified Data.ByteString.Lazy as Lazy
13-
import Data.Foldable (for_)
14-
import qualified Data.Map as Map
15-
import Data.Map (Map)
16-
import Data.Text (Text)
17-
import qualified Data.Text as T
8+
import Cardano.Tracer.Environment (TracerEnv (..))
9+
import Cardano.Tracer.Types (MetricsStores, NodeId, NodeName)
10+
1811
import Prelude hiding (head)
19-
import qualified Data.Bimap as Bimap
2012

2113
import Control.Concurrent.STM (atomically)
2214
import Control.Concurrent.STM.TVar (readTVar)
2315
import Data.Aeson (encode)
24-
import Cardano.Tracer.Environment (TracerEnv(..))
16+
import qualified Data.Bimap as Bimap
17+
import qualified Data.ByteString.Lazy as Lazy
18+
import Data.Foldable (for_)
19+
import Data.Map (Map)
20+
import qualified Data.Map as Map
21+
import Data.Text (Text)
22+
import qualified Data.Text as T
23+
import Network.HTTP.Types (ResponseHeaders, hContentType)
2524
import qualified System.Metrics as EKG
26-
import Cardano.Tracer.Types (NodeName, NodeId, MetricsStores)
2725
import Text.Blaze.Html (Html)
2826
import Text.Blaze.Html.Renderer.Utf8 (renderHtml)
29-
import Text.Blaze.Html5 (Markup, a, li, ul, body, title, head, (!), textValue, html, toHtml) -- hiding (map)
27+
import Text.Blaze.Html5 (Markup, a, body, head, html, li, textValue, title, toHtml, ul,
28+
(!))
3029
import Text.Blaze.Html5.Attributes hiding (title)
3130
import Text.Slugify (slugify)
3231

@@ -82,3 +81,11 @@ computeRoutes TracerEnv{teConnectedNodesNames, teAcceptedMetrics} = atomically d
8281
]
8382

8483
pure (RouteDictionary routes)
84+
85+
86+
87+
contentHdrJSON, contentHdrOpenMetrics, contentHdrUtf8Html, contentHdrUtf8Text :: ResponseHeaders
88+
contentHdrJSON = [(hContentType, "application/json")]
89+
contentHdrOpenMetrics = [(hContentType, "application/openmetrics-text; version=1.0.0; charset=utf-8")]
90+
contentHdrUtf8Html = [(hContentType, "text/html; charset=utf-8")]
91+
contentHdrUtf8Text = [(hContentType, "text/plain; charset=utf-8")]

0 commit comments

Comments
 (0)