otel-effectful-1.0.0: test/Effectful/OpenTelemetry/Exporter/Grafana/Tempo.hs
{-# OPTIONS_GHC -Wno-name-shadowing #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Effectful.OpenTelemetry.Exporter.Grafana.Tempo where
import Arbitrary
import Data.Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser, typeMismatch)
import Data.ByteString (ByteString)
import Data.ByteString.Base16 qualified as Base16
import Data.ByteString.Base64 qualified as Base64
import Data.Foldable (for_)
import Data.Functor.Syntax ((<&&>))
import Data.Maybe (listToMaybe)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Tuple.Extra (thd3)
import Effectful
import Effectful.Concurrent (Concurrent)
import Effectful.Environment (Environment)
import Effectful.Exception (catchSync, throwIO)
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.Protocol (Resource, Scope)
import Effectful.OpenTelemetry.Protocol.Attributes (Attributes)
import Effectful.OpenTelemetry.Protocol.Resource qualified as Resource
import Effectful.OpenTelemetry.Protocol.Scope qualified as Scope
import Effectful.OpenTelemetry.Protocol.Transport (Protocol (..))
import Effectful.OpenTelemetry.Timestamp (Timestamp (..))
import Effectful.OpenTelemetry.Tracing
( Tracing
, addEvent
, currentContext
, inOkSpan
, inSpan
, withContext
)
import Effectful.OpenTelemetry.Tracing.Span (Span (..))
import Effectful.OpenTelemetry.Tracing.Span.Context (Context (..))
import Effectful.OpenTelemetry.Tracing.Span.Context qualified as Span.Context
import Effectful.OpenTelemetry.Tracing.Span.Event (Event (..))
import Effectful.OpenTelemetry.Tracing.Span.ID qualified as Span.ID
import Effectful.OpenTelemetry.Tracing.Span.Kind (Kind (..))
import Effectful.OpenTelemetry.Tracing.Span.Kind qualified as Kind
import Effectful.OpenTelemetry.Tracing.Span.Status (Code, Status (..))
import Effectful.OpenTelemetry.Tracing.Span.Status qualified as Status
import Effectful.OpenTelemetry.Tracing.Trace.ID qualified as Trace.ID
import Effectful.OpenTelemetry.Tracing.Trace.State qualified as Trace.State
import Effectful.QuickCheck (arbitrary, generate)
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
instance FromJSON (Resource.Items Span) where
parseJSON = withObject "batch" \batch -> do
resource <- batch .:? "resource" .!= mempty
scopeItems <- batch .:? "scopeSpans" .!= []
pure Resource.Items{resource, scopeItems}
instance FromJSON Resource.Resource where
parseJSON = withObject "resource" $ fmap Resource.Resource . attributesAt "attributes"
instance FromJSON (Scope.Items Span) where
parseJSON = withObject "scopeSpans" \ss -> do
scope <- ss .:? "scope" .!= emptyScope
items <- ss .:? "spans" .!= []
pure Scope.Items{scope, items}
where
emptyScope = Scope.Scope{name = "", version = "", attributes = mempty}
instance FromJSON Scope.Scope where
parseJSON = withObject "scope" \sc -> do
name <- sc .:? "name" .!= ""
version <- sc .:? "version" .!= ""
attributes <- attributesAt "attributes" sc
pure Scope.Scope{..}
instance FromJSON Span where
parseJSON = withObject "Span" \o -> do
context <- do
traceId <- parseId Trace.ID.fromBytes =<< o .: "traceId"
spanId <- parseId Span.ID.fromBytes =<< o .: "spanId"
traceFlags <- o .:? "flags" .!= mempty
traceState <- maybe mempty Trace.State.fromText <$> o .:? "traceState"
pure Context{..}
name <- o .:? "name" .!= ""
parentSpanId <- traverse (parseId Span.ID.fromBytes) =<< o .:? "parentSpanId"
kind <- o .: "kind"
startTime <- parseTimestamp =<< o .: "startTimeUnixNano"
endTime <- mapM parseTimestamp =<< o .:? "endTimeUnixNano"
attributes <- attributesAt "attributes" o
events <- Seq.fromList <$> o .:? "events" .!= []
status <- o .:? "status" .!= Status.unset
pure Span{..}
where
parseId :: (ByteString -> Either String a) -> Text.Text -> Parser a
parseId fromBytes t@(Text.encodeUtf8 -> bs) =
case (fromBytes =<< Base16.decode bs, fromBytes =<< Base64.decode bs) of
(Right a, _) -> pure a
(_, Right a) -> pure a
(Left hexErr, Left b64Err) ->
fail $ "neither valid hex (" <> hexErr <> ") nor base64 (" <> b64Err <> "): " <> Text.unpack t
instance FromJSON Event where
parseJSON = withObject "event" \e -> do
name <- e .: "name"
time <- parseTimestamp =<< e .: "timeUnixNano"
attributes <- attributesAt "attributes" e
pure Event{..}
instance FromJSON Status where
parseJSON = withObject "status" \st -> do
message <- st .:? "message" .!= ""
code <- st .:? "code" .!= Status.Unset
pure Status{..}
instance FromJSON Code where
parseJSON (Number n) = case round n :: Int of
0 -> pure Status.Unset
1 -> pure Status.Ok
2 -> pure Status.Error
n' -> fail $ "unknown status code: " <> show n'
parseJSON (String s) = case s of
"STATUS_CODE_UNSET" -> pure Status.Unset
"STATUS_CODE_OK" -> pure Status.Ok
"STATUS_CODE_ERROR" -> pure Status.Error
_ -> fail $ "unknown status code: " <> Text.unpack s
parseJSON v = typeMismatch "Status.Code" v
instance FromJSON Kind where
parseJSON (Number n) = case round n :: Int of
1 -> pure Internal
2 -> pure Server
3 -> pure Client
4 -> pure Producer
5 -> pure Consumer
n' -> fail $ "unknown span kind: " <> show n'
parseJSON (String s) = case s of
"SPAN_KIND_INTERNAL" -> pure Internal
"SPAN_KIND_SERVER" -> pure Server
"SPAN_KIND_CLIENT" -> pure Client
"SPAN_KIND_PRODUCER" -> pure Producer
"SPAN_KIND_CONSUMER" -> pure Consumer
_ -> fail $ "unknown span kind: " <> Text.unpack s
parseJSON v = typeMismatch "Kind" v
parseTimestamp :: Value -> Parser Timestamp
parseTimestamp (Number n) = pure . Timestamp $ round n
parseTimestamp (String t) = maybe (fail "invalid timestamp") (pure . Timestamp) . readMaybe $ Text.unpack t
parseTimestamp v = typeMismatch "Timestamp" v
attributesAt :: Key.Key -> Object -> Parser Attributes
attributesAt k o = o .:? k .!= mempty
fetchTrace :: (IOE :> es) => URI -> Text -> Eff es (Either String [(Resource, Scope, Span)])
fetchTrace baseUri traceIdHex = do
let url = show baseUri{uriPath = "/api/traces/" <> Text.unpack traceIdHex}
runHttpClientTls $ do
resp <- httpLbs $ parseRequest_ url
let code = statusCode $ responseStatus resp
pure $ case code of
200 -> case decode (responseBody resp) of
Just (Object o) -> case KeyMap.lookup "batches" o of
Just batches -> case fromJSON batches of
Success (batchItems :: [Resource.Items Span]) -> case flattenBatches batchItems of
[] -> Left "Tempo returned trace with no spans"
ss -> Right ss
Error err -> Left $ "Tempo response failed to parse: " <> err
Nothing -> Left "Tempo response had no batches field"
_ -> Left "Tempo response was not a JSON object"
_ -> Left $ "Tempo returned HTTP " <> show code
flattenBatches :: [Resource.Items a] -> [(Resource, Scope, a)]
flattenBatches batchItems =
[ (resource, scope, s)
| Resource.Items{resource, scopeItems} <- batchItems
, Scope.Items{scope, items} <- scopeItems
, s <- items
]
spec
:: forall es
. (HasCallStack, IOE :> es, HUnit :> es, Hspec :> es, Retry :> es, Timeout :> es)
=> Protocol
-> URI
-> Resource
-> Scope
-> ( forall a
. Eff '[Metrics, Logging, Tracing, Environment, Timeout, Retry, Concurrent, IOE] a
-> Eff es (a, [Span])
)
-> Eff es ()
spec (protocolLabel -> label) tempoUri resource scope runTest = describe "Tempo" . parallel $ do
Marker suffix <- liftIO $ generate arbitrary
let nameFor :: Text -> Text
nameFor kind = label <> "-" <> kind <> "-" <> suffix
roundTrip act check = do
checkReady "Tempo" tempoUri
((), captured) <- runTest act
firstSpan <- maybe (assertFailure "no in-memory spans") pure $ listToMaybe captured
pollOrFail (fetchTrace tempoUri $ Trace.ID.toHex firstSpan.context.traceId) (check captured)
prop "span round-trip" \(Marker marker) spanKind (MarkerAttributes attrs) traceState events -> do
let innerSpanName = label <> "-trace-" <> marker
outerSpanName = innerSpanName <> "-outer"
roundTrip
( inSpan outerSpanName Kind.Internal mempty do
ctx <- currentContext <&&> \ctx -> ctx{Span.Context.traceState}
maybe id withContext ctx . inSpan innerSpanName spanKind attrs $
mapM_ @[] addEvent events
)
\captured fetched -> do
captured `shouldBe` (thd3 <$> fetched)
for_ fetched \(tempoResource, tempoScope, _) -> do
tempoScope `shouldBe` scope
tempoResource `shouldBe` resource
it "Ok status round-trip" $
roundTrip (inOkSpan (nameFor "ok") Kind.Internal mempty $ pure ()) \captured fetched ->
(thd3 <$> fetched) `shouldBe` captured
it "Error status round-trip" $
roundTrip
( (inSpan (nameFor "error") Kind.Internal mempty . throwIO . userError $ "boom-" <> Text.unpack suffix)
`catchSync` const (pure ())
)
\captured fetched -> (thd3 <$> fetched) `shouldBe` captured