effectful-tracing-0.1.0.0: src/Effectful/Tracing/Instrumentation/Wai.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
-- |
-- Module : Effectful.Tracing.Instrumentation.Wai
-- Description : Tracing middleware for WAI applications.
-- Copyright : (c) The effectful-tracing contributors
-- License : BSD-3-Clause
-- Stability : experimental
--
-- A WAI 'Middleware' that opens a @server@-kind span around each request. It
-- continues an inbound distributed trace (reading @traceparent@ \/ @tracestate@
-- with "Effectful.Tracing.Propagation"), records the request and response
-- following the stable OpenTelemetry HTTP semantic conventions (the
-- @http.request.method@ \/ @url.path@ \/ @http.response.status_code@ set; see
-- "Effectful.Tracing.SemConv"), and lets the shared span lifecycle record any
-- exception as a span error before it propagates.
--
-- Because span management lives in @'Eff' es@ but WAI runs in 'IO', the
-- middleware takes an unlift function @forall a. 'Eff' es a -> 'IO' a@. Obtain
-- one with effectful's @withEffToIO@ (or @withRunInIO@) at the point you start
-- the server:
--
-- > import Effectful
-- >
-- > server :: (IOE :> es, Tracer :> es) => Application -> Eff es ()
-- > server app = withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO ->
-- > Warp.run 8080 (traceMiddleware runInIO app)
--
-- A real server handles requests concurrently, so the unlift must tolerate
-- concurrent invocation: use a concurrent unlift strategy, not the default
-- sequential one.
module Effectful.Tracing.Instrumentation.Wai
( -- * Middleware
traceMiddleware
, traceMiddlewareWith
-- * Span naming
, defaultSpanName
-- * Building blocks
, requestAttributes
) where
import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient)
import Network.HTTP.Types (HttpVersion (httpMajor, httpMinor), Status (statusCode, statusMessage))
import Network.Wai
( Middleware
, Request
, httpVersion
, isSecure
, rawPathInfo
, rawQueryString
, requestHeaders
, requestMethod
, responseStatus
)
import Effectful (Eff, IOE, (:>))
import Effectful.Tracing
( Attribute
, SpanArguments (attributes, kind)
, SpanKind (Server)
, SpanStatus (Error)
, Tracer
, addAttribute
, defaultSpanArguments
, extractContext
, setStatus
, withRemoteParent
, withSpan'
, (.=)
)
import Effectful.Tracing.SemConv qualified as SemConv
-- | Wrap an 'Network.Wai.Application' so every request runs inside a
-- @server@-kind span, naming the span with 'defaultSpanName' (the request
-- method). See 'traceMiddlewareWith' to supply your own name (for example the
-- matched route, which produces lower-cardinality, more useful span names when
-- the routing layer knows it).
traceMiddleware
:: (IOE :> es, Tracer :> es)
=> (forall a. Eff es a -> IO a)
-- ^ Run an @'Eff' es@ action in 'IO'. Must tolerate concurrent calls; see the
-- module header.
-> Middleware
traceMiddleware = traceMiddlewareWith defaultSpanName
-- | 'traceMiddleware' with an explicit span-naming function. The OpenTelemetry
-- HTTP conventions recommend a low-cardinality name such as @\"{method}
-- {route}\"@; pass the matched route here when you have it, and avoid putting the
-- raw path (which is high-cardinality) in the name.
traceMiddlewareWith
:: (IOE :> es, Tracer :> es)
=> (Request -> Text)
-> (forall a. Eff es a -> IO a)
-> Middleware
traceMiddlewareWith nameFor runInIO app req respond =
runInIO (continueRemote (withSpan' (nameFor req) serverArgs body))
where
-- Rejoin an inbound distributed trace when the headers carry one; otherwise
-- this request roots a new trace.
continueRemote =
maybe id withRemoteParent (extractContext (requestHeaders req))
serverArgs =
defaultSpanArguments
{ kind = Server
, attributes = requestAttributes req
}
body = do
-- Capture the response status as it flows back through the responder, so
-- it can be recorded on the span before the scope closes.
statusRef <- liftIO (newIORef Nothing)
let respond' response = do
-- Project and force the status before storing it, so the ref does
-- not retain the whole response (body included) until the span
-- closes.
let !status = responseStatus response
writeIORef statusRef (Just status)
respond response
received <- liftIO (app req respond')
mStatus <- liftIO (readIORef statusRef)
case mStatus of
Nothing -> pure ()
Just status -> do
addAttribute SemConv.httpResponseStatusCode (statusCode status)
-- 5xx marks the server span as failed (4xx is a client error, not the
-- server's, so it leaves the status unset per the conventions).
when (statusCode status >= 500) $
setStatus (Error (decodeUtf8Lenient (statusMessage status)))
pure received
-- | The default span name: the HTTP request method (for example @\"GET\"@). This
-- is deliberately low-cardinality. When the route template is known, prefer
-- 'traceMiddlewareWith' to name the span after it.
defaultSpanName :: Request -> Text
defaultSpanName = decodeUtf8Lenient . requestMethod
-- | The request attributes recorded at span start, following the stable HTTP
-- and URL semantic conventions (see "Effectful.Tracing.SemConv"). @url.query@ is
-- recorded only when the request carries a query string.
requestAttributes :: Request -> [Attribute]
requestAttributes req =
[ SemConv.httpRequestMethod .= decodeUtf8Lenient (requestMethod req)
, SemConv.urlPath .= decodeUtf8Lenient (rawPathInfo req)
, SemConv.urlScheme .= (if isSecure req then "https" else "http" :: Text)
, SemConv.networkProtocolVersion .= protocolVersion (httpVersion req)
]
<> [SemConv.urlQuery .= query | not (T.null query)]
where
-- 'rawQueryString' includes the leading '?'; the @url.query@ convention is
-- the query without it, and the attribute is omitted when there is none.
query =
let raw = decodeUtf8Lenient (rawQueryString req)
in fromMaybe raw (T.stripPrefix "?" raw)
-- | Render an 'HttpVersion' as the OTel @network.protocol.version@ value, for
-- example @\"1.1\"@.
protocolVersion :: HttpVersion -> Text
protocolVersion v = T.pack (show (httpMajor v)) <> "." <> T.pack (show (httpMinor v))