effectful-tracing-0.1.0.0: test/Effectful/Tracing/Instrumentation/WaiSpec.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.Instrumentation.WaiSpec
-- Description : Tests for the WAI tracing middleware.
--
-- The middleware is exercised against a hand-built 'Request' and a trivial
-- 'Application', run through the in-memory interpreter so the resulting spans
-- can be inspected directly. We assert the shape a server span should have: a
-- @server@ kind, a name and the stable HTTP \/ URL attributes from the request,
-- the response status code, a 5xx mapped to an error status, an inbound
-- @traceparent@ continued as the parent trace, and an exception in the handler
-- recorded and re-raised.
module Effectful.Tracing.Instrumentation.WaiSpec
( tests
) where
import Control.Exception (SomeException, throwIO)
import Control.Monad (void)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.List (find)
import Data.Maybe (isNothing)
import Data.Text (Text)
import Network.HTTP.Types (Status, status200, status404, status503)
import Network.Wai
( Application
, Request (rawPathInfo, rawQueryString, requestHeaders, requestMethod)
, defaultRequest
, responseLBS
)
import Network.Wai.Internal (ResponseReceived (ResponseReceived))
import Effectful (Eff, IOE, UnliftStrategy (SeqUnlift), runEff, withEffToIO, (:>))
import Effectful.Exception qualified as Exc
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import Effectful.Tracing
( Span (spanAttributes, spanContext, spanKind, spanName, spanParentContext, spanStatus)
, SpanContext (spanContextSpanId, spanContextTraceId)
, SpanKind (Server)
, SpanStatus (Error, Unset)
, Tracer
, spanIdToHex
, traceIdToHex
)
import Effectful.Tracing.Attribute (Attribute (Attribute), AttributeValue (AttrInt, AttrText))
import Effectful.Tracing.Instrumentation.Wai (traceMiddleware)
import Effectful.Tracing.Interpreter.InMemory
( newCapturedSpans
, readCapturedSpans
, runTracerInMemory
)
tests :: TestTree
tests =
testGroup
"Instrumentation.Wai"
[ testCase "opens a server span named for the method, with http attributes" $ do
(spans, outcome) <- runWai (get "/users") (ok "hi")
assertRight outcome
s <- single spans
spanName s @?= "GET"
spanKind s @?= Server
lookupText "http.request.method" s @?= Just "GET"
lookupText "url.path" s @?= Just "/users"
lookupText "url.scheme" s @?= Just "http"
lookupInt "http.response.status_code" s @?= Just 200
spanStatus s @?= Unset
, testCase "with no query string url.query is omitted" $ do
(spans, _) <- runWai (get "/users") (ok "hi")
s <- single spans
lookupText "url.query" s @?= Nothing
, testCase "records path and query in url.path and url.query" $ do
(spans, _) <- runWai (get "/search?q=cat") (ok "hi")
s <- single spans
lookupText "url.path" s @?= Just "/search"
lookupText "url.query" s @?= Just "q=cat"
, testCase "a 4xx leaves the span status unset" $ do
(spans, _) <- runWai (get "/missing") (respondWith status404 "nope")
s <- single spans
lookupInt "http.response.status_code" s @?= Just 404
spanStatus s @?= Unset
, testCase "a 5xx marks the span as an error" $ do
(spans, _) <- runWai (get "/boom") (respondWith status503 "down")
s <- single spans
lookupInt "http.response.status_code" s @?= Just 503
assertError (spanStatus s)
, testCase "with no inbound traceparent the span is a root" $ do
(spans, _) <- runWai (get "/") (ok "hi")
s <- single spans
assertBool "server span is a root" (isNothing (spanParentContext s))
, testCase "continues an inbound distributed trace" $ do
let req = withTraceparent traceparentValue (get "/users")
(spans, _) <- runWai req (ok "hi")
s <- single spans
-- The server span joins the remote trace and points at the remote span.
traceIdToHex (spanContextTraceId (spanContext s)) @?= remoteTraceHex
case spanParentContext s of
Just parent -> spanIdToHex (spanContextSpanId parent) @?= remoteSpanHex
Nothing -> assertFailure "expected the server span to have a remote parent"
, testCase "records and re-raises an exception thrown by the handler" $ do
(spans, outcome) <- runWai (get "/throws") boom
case outcome of
Left _ -> pure ()
Right () -> assertFailure "expected the handler exception to propagate"
s <- single spans
assertError (spanStatus s)
]
-- W3C traceparent vector: version 00, a known trace id and span id, sampled.
traceparentValue :: ByteString
traceparentValue = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
remoteTraceHex :: Text
remoteTraceHex = "0af7651916cd43dd8448eb211c80319c"
remoteSpanHex :: Text
remoteSpanHex = "b7ad6b7169203331"
-- | Run the middleware around an application for one request, returning the
-- captured spans and whether the request completed or threw.
runWai :: Request -> Application -> IO ([Span], Either SomeException ())
runWai req app = runEff $ do
cap <- newCapturedSpans
outcome <- Exc.try (runTracerInMemory cap (runOnce req app))
spans <- readCapturedSpans cap
pure (spans, outcome)
where
runOnce :: (Tracer :> es, IOE :> es) => Request -> Application -> Eff es ()
runOnce r a =
withEffToIO SeqUnlift $ \runInIO ->
void (traceMiddleware runInIO a r discardResponse)
-- | A responder that ignores the response (we read what we need from the span).
discardResponse :: a -> IO ResponseReceived
discardResponse _ = pure ResponseReceived
-- Request builders ----------------------------------------------------------
-- | Build a GET request, splitting any query string off the path the way WAI
-- does: @rawPathInfo@ holds the path and @rawQueryString@ holds the query
-- (including its leading @?@).
get :: ByteString -> Request
get raw =
defaultRequest
{ requestMethod = "GET"
, rawPathInfo = path
, rawQueryString = query
}
where
(path, query) = BS.break (== qMark) raw
qMark = 63 -- '?'
withTraceparent :: ByteString -> Request -> Request
withTraceparent value req =
req {requestHeaders = ("traceparent", value) : requestHeaders req}
-- Application builders -------------------------------------------------------
ok :: LBS.ByteString -> Application
ok = respondWith status200
respondWith :: Status -> LBS.ByteString -> Application
respondWith st body _ respond = respond (responseLBS st [] body)
boom :: Application
boom _ _ = throwIO (userError "handler exploded")
-- Assertions / lookups -------------------------------------------------------
single :: [a] -> IO a
single [x] = pure x
single other = assertFailure ("expected exactly one span, got " <> show (length other))
assertRight :: Either SomeException () -> IO ()
assertRight (Right ()) = pure ()
assertRight (Left err) = assertFailure ("expected success, got exception: " <> show err)
assertError :: SpanStatus -> IO ()
assertError (Error _) = pure ()
assertError other = assertFailure ("expected Error status, got " <> show other)
lookupText :: Text -> Span -> Maybe Text
lookupText key s =
case findAttr key s of
Just (AttrText t) -> Just t
_ -> Nothing
lookupInt :: Text -> Span -> Maybe Int
lookupInt key s =
case findAttr key s of
Just (AttrInt n) -> Just (fromIntegral n)
_ -> Nothing
findAttr :: Text -> Span -> Maybe AttributeValue
findAttr key s =
fmap (\(Attribute _ v) -> v) (find (\(Attribute k _) -> k == key) (spanAttributes s))