11{-# LANGUAGE NamedFieldPuns #-}
22{-# LANGUAGE OverloadedStrings #-}
33{-# LANGUAGE ScopedTypeVariables #-}
4+ {-# LANGUAGE ViewPatterns #-}
45
56module Cardano.Tracer.Handlers.Metrics.Prometheus
67 ( runPrometheusServer
@@ -14,23 +15,27 @@ import Cardano.Tracer.MetaTrace
1415
1516import Prelude hiding (head )
1617
18+ import Control.Applicative ((<|>) )
19+ import Data.Aeson (ToJSON (.. ), encode , pairs , (.=) )
1720import qualified Data.ByteString as ByteString
18- import Data.ByteString.Builder (stringUtf8 )
1921import Data.Functor ((<&>) )
20- import Data.Text (Text )
22+ import qualified Data.Map as Map (Map , empty , fromList )
23+ import Data.Maybe
24+ import Data.Text as T (Text , cons )
25+ import qualified Data.Text.Encoding as T (decodeUtf8 )
2126import qualified Data.Text.Lazy as TL
2227import Data.Text.Lazy.Builder (Builder )
2328import qualified Data.Text.Lazy.Encoding as TL
2429import Network.HTTP.Types
25- import Network.Wai hiding ( responseHeaders )
30+ import Network.Wai
2631import Network.Wai.Handler.Warp (defaultSettings , runSettings )
2732import System.Metrics as EKG (Store , sampleAll )
2833import System.Time.Extra (sleep )
2934
3035-- | Runs a simple HTTP server that listens on @endpoint@.
3136--
3237-- At the root, it lists the connected nodes, either as HTML or JSON, depending
33- -- on the requests 'Accept: ' header.
38+ -- on the request's 'Accept: ' header.
3439--
3540-- Routing is dynamic, depending on the connected nodes. A valid URL is derived
3641-- from the nodeName configured for the connecting node. E.g. a node name
@@ -40,10 +45,6 @@ import System.Time.Extra (sleep)
4045-- # TYPE Mem_resident_int gauge
4146-- # HELP Mem_resident_int Kernel-reported RSS (resident set size)
4247-- Mem_resident_int 103792640
43- -- # TYPE rts_gc_max_bytes_used gauge
44- -- rts_gc_max_bytes_used 5811512
45- -- # TYPE rts_gc_gc_cpu_ms counter
46- -- rts_gc_gc_cpu_ms 50
4748-- # TYPE RTS_gcMajorNum_int gauge
4849-- # HELP RTS_gcMajorNum_int Major GCs
4950-- RTS_gcMajorNum_int 4
@@ -56,6 +57,23 @@ import System.Time.Extra (sleep)
5657-- # TYPE nodeCannotForge_int gauge
5758-- # HELP nodeCannotForge_int How many times was this node unable to forge [a block]?
5859--
60+ -- The `/targets` path can be used for Prometheus HTTP service discovery. This lets
61+ -- Prometheus dynamically discover all connected nodes, and scrape their metrics.
62+ -- Below is a minimal example of a corresponding job definition that goes into the
63+ -- `prometheus.yml` configuration:
64+ --
65+ -- - job_name: "cardano-tracer"
66+ --
67+ -- http_sd_configs:
68+ -- - url: 'http://127.0.0.1:3200/targets' # <-- Your cardano-tracer's real hostname:prometheus port
69+ --
70+ -- Each target will have a label "node_name" which corresponds to the TraceOptionNodeName setting in the node config.
71+ --
72+ -- In cardano-tracer's config, you can optionally provide additional labels to be attached to *all* targets
73+ -- (default is no additional labels):
74+ -- "prometheusLabels": {
75+ -- "<labelname>": "<labelvalue>", ...
76+ -- }
5977runPrometheusServer
6078 :: TracerEnv
6179 -> Endpoint
@@ -71,50 +89,79 @@ runPrometheusServer tracerEnv endpoint computeRoutes_autoUpdate = do
7189 { ttPrometheusEndpoint = endpoint
7290 }
7391 runSettings (setEndpoint endpoint defaultSettings) do
74- renderPrometheus computeRoutes_autoUpdate noSuffix teMetricsHelp
92+ renderPrometheus computeRoutes_autoUpdate noSuffix teMetricsHelp promLabels
7593 where
7694 TracerEnv
7795 { teTracer
78- , teConfig = TracerConfig { metricsNoSuffix }
96+ , teConfig = TracerConfig { metricsNoSuffix, prometheusLabels }
7997 , teMetricsHelp
8098 } = tracerEnv
8199
82- noSuffix = or @ Maybe metricsNoSuffix
100+ noSuffix = or @ Maybe metricsNoSuffix
101+ promLabels = fromMaybe Map. empty prometheusLabels
83102
84103renderPrometheus
85104 :: IO RouteDictionary
86105 -> Bool
87106 -> [(Text , Builder )]
107+ -> Map. Map Text Text
88108 -> Application
89- renderPrometheus computeRoutes_autoUpdate noSuffix helpTextDict request send = do
109+ renderPrometheus computeRoutes_autoUpdate noSuffix helpTextDict promLabels request send = do
90110 routeDictionary :: RouteDictionary <-
91111 computeRoutes_autoUpdate
92112
93- let acceptHeader :: Maybe ByteString. ByteString
94- acceptHeader = lookup hAccept $ requestHeaders request
95-
96- let wantsJson, wantsOpenMetrics :: Bool
97- wantsJson = all @ Maybe (" application/json" `ByteString.isInfixOf` ) acceptHeader
98- wantsOpenMetrics = all @ Maybe (" application/openmetrics-text" `ByteString.isInfixOf` ) acceptHeader
99-
100113 case pathInfo request of
101114
102115 [] ->
103116 send $ uncurry (responseLBS status200) $ if wantsJson
104117 then (contentHdrJSON , renderJson routeDictionary)
105118 else (contentHdrUtf8Html, renderListOfConnectedNodes " Prometheus metrics" routeDictionary)
106119
120+ [" targets" ]
121+ | wantsJson
122+ -> serviceDiscovery routeDictionary
123+
124+ | otherwise
125+ -> wrongMType
126+
107127 route: _
108128 | Just (store :: EKG. Store , _ ) <- lookup route (getRouteDictionary routeDictionary)
109- -> do metrics <- getMetricsFromNode noSuffix helpTextDict store
110- send $ responseBuilder status200
111- (if wantsOpenMetrics then contentHdrOpenMetrics else contentHdrUtf8Text)
112- (TL. encodeUtf8Builder metrics)
129+ -> metricsExposition store
113130
114131 | otherwise
115- -> send $ responseBuilder status404 contentHdrUtf8Text do
116- " Not found: "
117- <> stringUtf8 (show route)
132+ -> notFound route
133+
134+ where
135+ acceptHeader :: Maybe ByteString. ByteString
136+ acceptHeader = lookup hAccept $ requestHeaders request
137+
138+ wantsJson , wantsOpenMetrics :: Bool
139+ wantsJson = all @ Maybe (" application/json" `ByteString.isInfixOf` ) acceptHeader
140+ wantsOpenMetrics = all @ Maybe (" application/openmetrics-text" `ByteString.isInfixOf` ) acceptHeader
141+
142+ -- we might support the more complex 'Forward:' header in the future
143+ getHostNameRequest :: Maybe ByteString. ByteString
144+ getHostNameRequest =
145+ lookup " x-forwarded-host" (requestHeaders request)
146+ <|> requestHeaderHost request
147+
148+ metricsExposition store = do
149+ metrics <- getMetricsFromNode noSuffix helpTextDict store
150+ send $ responseBuilder status200
151+ (if wantsOpenMetrics then contentHdrOpenMetrics else contentHdrPrometheus)
152+ (TL. encodeUtf8Builder metrics)
153+
154+ serviceDiscovery (RouteDictionary routeDict) =
155+ send $ responseLBS status200 contentHdrJSON $
156+ case getHostNameRequest of
157+ Just (T. decodeUtf8 -> hostName) -> encode
158+ [PSD (slug, nodeName, hostName, promLabels) | (slug, (_, nodeName)) <- routeDict]
159+ Nothing -> " []"
160+
161+ notFound t = send $ responseLBS status404 contentHdrUtf8Text $
162+ " Not found: " <> (TL. encodeUtf8 . TL. fromStrict) t
163+ wrongMType = send $ responseLBS status415 contentHdrUtf8Text
164+ " Unsupported Media Type"
118165
119166getMetricsFromNode
120167 :: Bool
@@ -123,3 +170,21 @@ getMetricsFromNode
123170 -> IO TL. Text
124171getMetricsFromNode noSuffix helpTextDict ekgStore =
125172 sampleAll ekgStore <&> renderExpositionFromSampleWith helpTextDict noSuffix
173+
174+
175+ -- This wrapper type implements the Prometheus HTTP SD format
176+ -- cf. https://prometheus.io/docs/prometheus/latest/http_sd
177+ -- It is local to this module, and never expected to provide an Aeson.Value.
178+ newtype PrometheusServiceDiscovery = PSD (Text , Text , Text , Map. Map Text Text )
179+
180+ instance ToJSON PrometheusServiceDiscovery where
181+ toJSON _ = error " ToJSON.toJSON(PrometheusServiceDiscovery): implementation error"
182+
183+ toEncoding (PSD (slug, nodeName, hostName, labelMap)) = pairs $
184+ (" targets" .= [hostName])
185+ <> (" labels" .= (labels <> labelMap))
186+ where
187+ labels = Map. fromList
188+ [ (" __metrics_path__" , ' /' `T.cons` slug)
189+ , (" node_name" , nodeName)
190+ ]
0 commit comments