packages feed

otel-effectful-1.0.0: test/Effectful/OpenTelemetry/Exporter/Grafana/Mimir.hs

module Effectful.OpenTelemetry.Exporter.Grafana.Mimir where

import Arbitrary
import Control.Applicative ((<|>))
import Data.Aeson (Key, Value (..), decode, toJSON)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Char (isAlphaNum, isAscii)
import Data.Foldable (toList)
import Data.Functor ((<&>))
import Data.Maybe (listToMaybe)
import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word64)
import Effectful
import Effectful.Concurrent (Concurrent)
import Effectful.Environment (Environment)
import Effectful.HUnit (HUnit, assertFailure)
import Effectful.Hspec
import Effectful.HttpClient (httpLbs, parseRequest_, responseBody, runHttpClientTls)
import Effectful.OpenTelemetry.Exporter.Grafana.Polling (checkReady, pollOrFail)
import Effectful.OpenTelemetry.Logging (Logging)
import Effectful.OpenTelemetry.Metrics (Metrics)
import Effectful.OpenTelemetry.Metrics.Counter qualified as Counter
import Effectful.OpenTelemetry.Metrics.Gauge qualified as Gauge
import Effectful.OpenTelemetry.Metrics.Histogram qualified as Histogram
import Effectful.OpenTelemetry.Metrics.Measurement
    ( HistogramDataPoint (..)
    , Measurement (..)
    , NumberDataPoint (..)
    )
import Effectful.OpenTelemetry.Metrics.Metadata (Metadata (..))
import Effectful.OpenTelemetry.Metrics.Metadata qualified as Metadata
import Effectful.OpenTelemetry.Metrics.UpDownCounter qualified as UpDownCounter
import Effectful.OpenTelemetry.Protocol.Attributes (Attributes)
import Effectful.OpenTelemetry.Protocol.Attributes qualified as Attributes
import Effectful.OpenTelemetry.Protocol.Transport (Protocol (..))
import Effectful.OpenTelemetry.Tracing (Tracing)
import Effectful.QuickCheck (NonNegative (..), Small (..))
import Effectful.Retry (Retry)
import Effectful.Timeout (Timeout)
import GHC.Stack (HasCallStack)
import Network.HTTP.Client (responseStatus)
import Network.HTTP.Types.Status (statusCode)
import Network.URI (URI (..))
import Text.Read (readMaybe)
import Util
import Prelude

data NumberSample = NumberSample
    { value :: Scientific
    , attributes :: Attributes
    }
    deriving stock (Eq, Show)

numberSample :: Measurement -> Maybe NumberSample
numberSample m = numberDataPoint m <&> \NumberDataPoint{..} -> NumberSample{..}

extractNumberSample :: Value -> Maybe NumberSample
extractNumberSample v = do
    Object res <- Just v
    value <- extractInstantValue v
    Object metric <- KeyMap.lookup "metric" res
    pure
        NumberSample
            { value
            , attributes =
                Attributes.fromList
                    [ (k, val)
                    | (k, val) <- KeyMap.toList metric
                    , k `notElem` reservedLabels
                    ]
            }

data HistogramStats = HistogramStats
    { count :: Word64
    , total :: Maybe Scientific
    , buckets :: Int
    }
    deriving stock (Eq, Show)

histogramStats :: Measurement -> Maybe HistogramStats
histogramStats m =
    histogramDataPoint m <&> \dp ->
        HistogramStats
            { count = dp.count
            , total = dp.sum
            , buckets = length dp.explicitBounds + 1
            }

fetchInstant :: (IOE :> es) => (Value -> Maybe a) -> URI -> Text -> Eff es (Either String a)
fetchInstant extract baseUri metricName = do
    let url =
            show
                baseUri
                    { uriPath = "/prometheus/api/v1/query"
                    , uriQuery = "?query=" <> Text.unpack metricName
                    }
    runHttpClientTls $ do
        resp <- httpLbs $ parseRequest_ url
        let code = statusCode $ responseStatus resp
        pure $ case code of
            200 -> case decode (responseBody resp) of
                Just (Object obj)
                    | Just (Object d) <- KeyMap.lookup "data" obj
                    , Just (Array results) <- KeyMap.lookup "result" d ->
                        case listToMaybe (toList results) >>= extract of
                            Just v -> Right v
                            Nothing -> Left $ "Mimir: no samples for " <> Text.unpack metricName
                _ -> Left "Mimir response was not the expected shape"
            _ -> Left $ "Mimir returned HTTP " <> show code

fetchMetric :: (IOE :> es) => URI -> Text -> Eff es (Either String Scientific)
fetchMetric = fetchInstant extractInstantValue

fetchNumberSample :: (IOE :> es) => URI -> Text -> Eff es (Either String NumberSample)
fetchNumberSample = fetchInstant extractNumberSample

extractInstantValue :: Value -> Maybe Scientific
extractInstantValue v = do
    Object res <- Just v
    Array a <- KeyMap.lookup "value" res
    case toList a of
        [_, String txt] -> readMaybe $ Text.unpack txt
        _ -> Nothing

reservedLabels :: [Key]
reservedLabels = ["__name__", "job", "instance"]

hasDescriptionAndUnit :: Metadata -> Measurement -> Bool
hasDescriptionAndUnit metadata m = m.description == metadata.description && m.unit == metadata.unit

hasMetadata :: Metadata -> Measurement -> Bool
hasMetadata metadata m =
    hasDescriptionAndUnit metadata m
        && (numberAttributes <|> histogramAttributes) == Just metadata.attributes
  where
    numberAttributes = numberDataPoint m <&> (.attributes)
    histogramAttributes = histogramDataPoint m <&> (.attributes)

sanitiseName :: Text -> Text
sanitiseName = Text.map \c -> if isAsciiAlphaNum c || c == '_' then c else '_'
  where
    isAsciiAlphaNum c = isAscii c && isAlphaNum c

spec
    :: forall es
     . (HasCallStack, IOE :> es, HUnit :> es, Hspec :> es, Retry :> es, Timeout :> es)
    => Protocol
    -> URI
    -> ( forall a
          . Eff '[Metrics, Logging, Tracing, Environment, Timeout, Retry, Concurrent, IOE] a
         -> Eff es (a, [Measurement])
       )
    -> Eff es ()
spec (protocolLabel -> label) mimirUri runTest = describe "Mimir" . parallel $ do
    let instrumentName kind marker = sanitiseName $ label <> "_" <> kind <> "_" <> marker
        numberRoundTrip metadata selector act = do
            checkReady "Mimir" mimirUri
            m <- only . snd =<< runTest act
            m `shouldSatisfy` hasDescriptionAndUnit metadata
            sample <- maybe (assertFailure "measurement had no numeric value") pure $ numberSample m
            pollOrFail (fetchNumberSample mimirUri selector) (`shouldBe` sample)

    prop "counter round-trip" \(Marker marker) metadata (NonNegative (Small value)) ->
        let name = instrumentName "counter" marker
         in numberRoundTrip metadata (name <> "_total") do
                c <- Counter.new name metadata
                Counter.add c value

    prop "up-down counter round-trip" \(Marker marker) metadata (Small value) ->
        let name = instrumentName "updown" marker
         in numberRoundTrip metadata name do
                c <- UpDownCounter.new name metadata
                UpDownCounter.add c value

    prop "gauge round-trip" \(Marker marker) metadata (NonNegative (Small value)) ->
        let name = instrumentName "gauge" marker
         in numberRoundTrip metadata name do
                g <- Gauge.new name metadata
                Gauge.set g value

    prop "instrument attributes as labels" \(Marker marker) (Marker labelValue) metadata (NonNegative (Small increment)) ->
        let name = instrumentName "labelled" marker
            labelKey = "attr_kind" :: Text
            metadata' = metadata{Metadata.attributes = Attributes.fromList [(Key.fromText labelKey, toJSON labelValue)]}
            selector = name <> "_total{" <> labelKey <> "=\"" <> labelValue <> "\"}"
         in numberRoundTrip metadata' selector do
                c <- Counter.new name metadata'
                Counter.add c increment

    prop "histogram round-trip" \(Marker marker) metadata (HistogramSamples samples) (HistogramBounds bounds) -> do
        checkReady "Mimir" mimirUri
        let base = sanitiseName $ label <> "_hist_" <> marker
            countSeries = base <> "_count"
            sumSeries = base <> "_sum"
        m <-
            only . snd =<< runTest do
                h <- Histogram.newWithBounds base bounds metadata
                mapM_ (Histogram.record h) samples
        m `shouldSatisfy` hasMetadata metadata
        stats <- maybe (assertFailure "measurement had no histogram data") pure $ histogramStats m
        total <- maybe (assertFailure "measurement had no sum") pure stats.total
        pollOrFail (fetchMetric mimirUri countSeries) (`shouldBe` fromIntegral stats.count)
        pollOrFail (fetchMetric mimirUri sumSeries) \fetched -> abs (fetched - total) `shouldSatisfy` (< 0.01)
        pollOrFail
            (fetchMetric mimirUri $ "count(" <> base <> "_bucket)")
            (`shouldBe` fromIntegral stats.buckets)
        pollOrFail
            (fetchMetric mimirUri $ "max(" <> base <> "_bucket)")
            (`shouldBe` fromIntegral stats.count)