packages feed

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

{-# OPTIONS_GHC -Wno-name-shadowing #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Effectful.OpenTelemetry.Exporter.Grafana.Loki where

import Arbitrary
import Control.Monad ((>=>))
import Data.Aeson
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser)
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful
import Effectful.Concurrent (Concurrent)
import Effectful.Environment (Environment)
import Effectful.HUnit (HUnit)
import Effectful.Hspec
import Effectful.HttpClient (httpLbs, parseRequest_, responseBody, runHttpClientTls)
import Effectful.OpenTelemetry.Exporter.Grafana.Polling (checkReady, pollOrFail)
import Effectful.OpenTelemetry.Logging (Logging, log)
import Effectful.OpenTelemetry.Logging.LogRecord (LogRecord (..))
import Effectful.OpenTelemetry.Logging.Severity (Severity)
import Effectful.OpenTelemetry.Metrics (Metrics)
import Effectful.OpenTelemetry.Protocol.Attributes (Attributes (..))
import Effectful.OpenTelemetry.Protocol.Transport (Protocol (..))
import Effectful.OpenTelemetry.Timestamp (Timestamp)
import Effectful.OpenTelemetry.Tracing (Tracing, inSpan)
import Effectful.OpenTelemetry.Tracing.Span.Context (Context (..))
import Effectful.OpenTelemetry.Tracing.Span.Kind qualified as Kind
import Effectful.Retry (Retry)
import Effectful.Timeout (Timeout)
import GHC.Generics (Generic)
import GHC.Stack (HasCallStack)
import Network.HTTP.Client (responseStatus)
import Network.HTTP.Types.Status (statusCode)
import Network.URI (URI (..), escapeURIString, isUnreserved)
import Text.Read (readMaybe)
import Util
import Prelude hiding (log)

-- HACK: Loki does not include the timestamp in the log records
data Stream = Stream
    { stream :: KeyMap Value
    , values :: [(Text, Text)]
    }
    deriving stock (Generic)
    deriving anyclass (FromJSON)

instance {-# OVERLAPPING #-} FromJSON [LogRecord] where
    parseJSON = parseJSON @[Stream] >=> fmap concat . traverse \Stream{..} -> traverse (parseEntry stream) values
      where
        parseEntry :: KeyMap Value -> (Text, Text) -> Parser LogRecord
        parseEntry labels (timeUnixNano, lineText) = do
            Object line <- either fail pure $ eitherDecodeStrictText lineText
            observedTimestamp <- parseJSON @Timestamp $ String timeUnixNano
            let o = line <> labels
            traceId <- o .:? "traceid"
            spanId <- o .:? "spanid"
            traceFlags <- o .:? "flags" .!= mempty
            let context = case (traceId, spanId) of
                    (Just traceId', Just spanId') ->
                        Just Context{traceId = traceId', spanId = spanId', traceFlags, traceState = mempty}
                    _ -> Nothing
            severity <- o .: "level"
            body <- o .:? "body" .!= Null
            attributes <- Attributes <$> (o .:? "attributes" .!= mempty)
            eventName <- o .:? "name"
            pure LogRecord{timestamp = observedTimestamp, ..}

instance FromJSON Severity where
    parseJSON = withText "Severity" $ maybe (fail "invalid severity") pure . readMaybe . Text.unpack

fetchLines :: (IOE :> es) => URI -> Text -> Eff es (Either String [LogRecord])
fetchLines baseUri needle = do
    let q = "{service_name=\"otel-effectful-test\"} |= `" <> Text.unpack needle <> "`"
        url =
            show
                baseUri
                    { uriPath = "/loki/api/v1/query_range"
                    , uriQuery = "?query=" <> escapeURIString isUnreserved q
                    }
    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 results <- KeyMap.lookup "result" d ->
                        case fromJSON results of
                            Success [] -> Left "Loki responded with no logs"
                            Success (records :: [LogRecord]) -> pure records
                            Error err -> Left $ "Loki response failed to parse: " <> err
                _ -> Left "Loki response was not the expected shape"
            _ -> Left $ "Loki returned HTTP " <> show code

spec
    :: (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, [LogRecord])
       )
    -> Eff es ()
spec (protocolLabel -> label) lokiUri runTest = describe "Loki" . parallel $ do
    prop "log line round-trip" \(Marker marker) (MarkerAttributes attrs) severity -> do
        checkReady "Loki" lokiUri
        let needle = label <> "-log-" <> marker
            spanName = needle <> "-span"
            eventName = "log.event." <> marker
        (_, capturedLogs) <-
            runTest . inSpan spanName Kind.Internal mempty $
                log severity (String needle) attrs (Just eventName)
        theLog <- only capturedLogs
        -- Alloy does not forward eventName
        let expected = theLog{eventName = Nothing}
        pollOrFail (fetchLines lokiUri needle) (`shouldBe` pure expected)