packages feed

prometheus-wai-0.2.0.0: src/System/Metrics/Prometheus/Wai/Middleware.hs

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module System.Metrics.Prometheus.Wai.Middleware
  ( registerWaiMetrics,
    WaiMetrics (..),
    instrumentWaiMiddleware,
    combineRegistrySample,
    MetricsEndpoint (..),
    defaultMetricsEndpoint,
    withLastSecondSamples,
    setSampleCache,
    metricsEndpointMiddleware,
  )
where

import Control.Concurrent.MVar
import Control.Monad
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Data.Word
import GHC.Clock (getMonotonicTimeNSec)
import qualified Network.HTTP.Types as HTTP
import Network.Wai as Wai (Middleware)
import qualified Network.Wai as Request
import qualified Network.Wai as Wai
import System.Metrics.Prometheus.Concurrent.Registry (Registry)
import qualified System.Metrics.Prometheus.Concurrent.Registry as Prometheus
import qualified System.Metrics.Prometheus.Concurrent.Registry as Registry
import qualified System.Metrics.Prometheus.Encode.Text as Prometheus
import qualified System.Metrics.Prometheus.Metric.Counter as Counter (inc)
import qualified System.Metrics.Prometheus.Metric.Counter as Prometheus (Counter)
import qualified System.Metrics.Prometheus.Metric.Histogram as Histogram (observe)
import qualified System.Metrics.Prometheus.Metric.Histogram as Prometheus (Histogram)
import qualified System.Metrics.Prometheus.MetricId as Labels
import qualified System.Metrics.Prometheus.MetricId as Prometheus (Labels (..))
import qualified System.Metrics.Prometheus.Registry as Prometheus (RegistrySample (..))

data WaiMetrics = WaiMetrics
  { waiMetricsStatusCode :: !(Map Int Prometheus.Counter),
    waiMetricsDuration :: !Prometheus.Histogram
  }

-- | Register the Wai metrics with the given labels at the given registry.
registerWaiMetrics :: Prometheus.Labels -> Registry -> IO WaiMetrics
registerWaiMetrics labels registry = do
  -- Status code counters
  -- Based on the codes defined at
  -- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status
  let codes =
        [100 .. 103]
          <> [200 .. 208]
          <> [226]
          <> [300 .. 304]
          <> [307, 308]
          <> [400 .. 418]
          <> [421 .. 426]
          <> [428, 429, 431, 451]
          <> [500 .. 508]
          <> [510, 511]
  let labelsForCode code = Labels.addLabel "http_response_code" (T.pack (show code)) labels
  waiMetricsStatusCode <- fmap M.fromList $ forM codes $ \code -> do
    counterForCode <- Prometheus.registerCounter "http_requests_total" (labelsForCode code) registry
    pure (code, counterForCode)

  -- Duration histogram
  let durationBounds =
        concat
          [ [1, 2, 3, 5],
            [10, 20 .. 100],
            [100, 200 .. 900],
            [1_000, 2_000 .. 9_000],
            [10_000]
          ]
  waiMetricsDuration <- Prometheus.registerHistogram "http_request_duration_milliseconds" labels durationBounds registry
  pure WaiMetrics {..}

-- | Record the given Wai metrics in a middleware.
instrumentWaiMiddleware :: WaiMetrics -> Wai.Middleware
instrumentWaiMiddleware WaiMetrics {..} application request sendResponse =
  let isWebSocketsReq =
        lookup "upgrade" (Wai.requestHeaders request) == Just "websocket"
      shouldInstrument =
        -- Don't instrument WebSocket requests because they don't have a
        -- response but some libraries still pretend that it does and will
        -- give it a 500 status code.
        -- Moreover, the 'latency' will be 'how long the connection was open'
        -- which is also useless.
        not isWebSocketsReq
   in if shouldInstrument
        then do
          begin <- getMonotonicTimeNSec
          application request $ \response -> do
            end <- getMonotonicTimeNSec

            -- Count the status code
            mapM_ Counter.inc (M.lookup (HTTP.statusCode (Wai.responseStatus response)) waiMetricsStatusCode)

            -- Count the application response duration
            let nanos = end - begin
                millis = fromIntegral nanos / 1_000_000
            Histogram.observe millis waiMetricsDuration

            sendResponse response
        else application request sendResponse

combineRegistrySample :: Prometheus.RegistrySample -> Prometheus.RegistrySample -> Prometheus.RegistrySample
combineRegistrySample (Prometheus.RegistrySample a) (Prometheus.RegistrySample b) =
  Prometheus.RegistrySample (M.union a b)

data MetricsEndpoint = MetricsEndpoint
  { -- | Path to serve metrics on, e.g. @"/metrics"@
    metricsEndpointPath :: ByteString,
    -- | Samples to collect.
    --
    -- Typically obtained via 'Registry.sample' but you can add others that you collect at the last second.
    metricsEndpointSample :: IO Prometheus.RegistrySample,
    -- | Cache duration in nanoseconds for the metrics samples.
    --
    -- You can set this to use multiple prometheus fetchers for redundancy
    -- without resampling every time.
    --
    -- Nothing means no caching
    metricsEndpointCacheDurationNanos :: Maybe Word64
  }

-- | Create a default 'MetricsEndpoint' serving at @"/metrics"@ with samples
-- from the given registry.
defaultMetricsEndpoint :: Registry -> MetricsEndpoint
defaultMetricsEndpoint registry =
  MetricsEndpoint
    { metricsEndpointPath = "/metrics",
      metricsEndpointSample = Registry.sample registry,
      metricsEndpointCacheDurationNanos = Nothing
    }

-- | Combine the given last second samples with the existing samples of the
-- endpoint.
withLastSecondSamples :: IO Prometheus.RegistrySample -> MetricsEndpoint -> MetricsEndpoint
withLastSecondSamples lastSecondSamples endpoint =
  endpoint
    { metricsEndpointSample =
        combineRegistrySample
          <$> metricsEndpointSample endpoint
          <*> lastSecondSamples
    }

setSampleCache :: Word64 -> MetricsEndpoint -> MetricsEndpoint
setSampleCache cacheDurationNanos endpoint = endpoint {metricsEndpointCacheDurationNanos = Just cacheDurationNanos}

metricsEndpointMiddleware :: (MonadIO m) => MetricsEndpoint -> m Wai.Middleware
metricsEndpointMiddleware MetricsEndpoint {..} =
  -- This function is almost always called in something like LoggingT IO, so we
  -- might as well lift already.
  liftIO $ do
    mCache <- forM metricsEndpointCacheDurationNanos $ \cacheDurationNanos -> do
      begin <- getMonotonicTimeNSec
      firstSample <- metricsEndpointSample
      var <- newMVar (begin, firstSample)
      pure (cacheDurationNanos, var)

    let sampleWithCache :: IO Prometheus.RegistrySample
        sampleWithCache = case mCache of
          Nothing -> metricsEndpointSample
          Just (cacheDurationNanos, var) -> do
            modifyMVar var $ \lastTup@(lastSampleTime, lastSample) -> do
              now <- getMonotonicTimeNSec
              if now - lastSampleTime < cacheDurationNanos
                then pure (lastTup, lastSample)
                else do
                  newSample <- metricsEndpointSample
                  let combinedSample = combineRegistrySample lastSample newSample
                  let newTup = (now, combinedSample)
                  pure (newTup, combinedSample)

    pure $ \application request sendResponse ->
      if Request.rawPathInfo request == metricsEndpointPath
        then do
          samples <- sampleWithCache
          sendResponse $
            Wai.responseBuilder HTTP.ok200 [(HTTP.hContentType, "text/plain; version=0.0.4; charset=utf-8")] $
              Prometheus.encodeMetrics samples
        else application request sendResponse