packages feed

effectful-tracing-0.1.0.0: src/Effectful/Tracing/Instrumentation/HttpClient.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Instrumentation.HttpClient
-- Description : Tracing wrappers for http-client requests.
-- Copyright   : (c) The effectful-tracing contributors
-- License     : BSD-3-Clause
-- Stability   : experimental
--
-- Wrap an @http-client@ request so it runs inside a @client@-kind span that
-- injects @traceparent@ \/ @tracestate@ into the outbound request (so the next
-- hop continues the trace), records the request and response following the
-- stable OpenTelemetry HTTP semantic conventions (see
-- "Effectful.Tracing.SemConv"), and is finalized even if the request throws.
--
-- > fetch :: (IOE :> es, Tracer :> es) => Manager -> Eff es (Response ByteString)
-- > fetch manager = do
-- >   req <- liftIO (parseRequest "https://example.com/widgets")
-- >   httpLbsTraced req manager
--
-- The API stays in @'Eff' es@ (unlike the WAI middleware, which must take an
-- unlift because WAI is an 'IO' type) because span management needs the effect
-- context and the request is driven from inside it. The optional manager-hook
-- approach is intentionally not provided: @'Manager'@'s @managerModifyRequest@
-- runs in 'IO' with no effect context, so it cannot carry the active span
-- without capturing an unlift at manager-construction time, and the request
-- wrapper here covers the need without that complication.
module Effectful.Tracing.Instrumentation.HttpClient
  ( httpLbsTraced
  ) where

import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LBS
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient)

import Network.HTTP.Client
  ( Manager
  , Request (requestHeaders)
  , Response (responseStatus)
  , getUri
  , httpLbs
  , method
  )
import Network.HTTP.Types (statusCode)
import Network.HTTP.Types.Header (HeaderName)

import Effectful (Eff, IOE, (:>))

import Effectful.Tracing
  ( SpanArguments (attributes, kind)
  , SpanKind (Client)
  , SpanStatus (Error)
  , Tracer
  , addAttribute
  , defaultSpanArguments
  , injectContext
  , setStatus
  , traceparentHeader
  , tracestateHeader
  , withSpan'
  , (.=)
  )
import Effectful.Tracing.Attribute (Attribute)
import Effectful.Tracing.SemConv qualified as SemConv

-- | Perform an @http-client@ request (via 'httpLbs') inside a @client@-kind
-- span. The span is named after the HTTP method (low cardinality), the active
-- context is injected into the outbound request as @traceparent@ \/ @tracestate@
-- so the downstream service continues this trace, and the request and response
-- are recorded following the stable OpenTelemetry HTTP semantic conventions (see
-- "Effectful.Tracing.SemConv"). A response status @>= 400@ sets the span status to
-- 'Error' (from the client's view the call failed); a thrown exception is
-- recorded by the shared span lifecycle and re-raised.
httpLbsTraced
  :: (IOE :> es, Tracer :> es)
  => Request
  -> Manager
  -> Eff es (Response LBS.ByteString)
httpLbsTraced req manager =
  withSpan' (decodeUtf8Lenient (method req)) clientArgs $ do
    -- Inside the span, inject the active (client) span's context, then send.
    headers <- injectContext
    response <- liftIO (httpLbs (injectHeaders headers req) manager)
    let status = statusCode (responseStatus response)
    addAttribute SemConv.httpResponseStatusCode status
    when (status >= 400) $
      setStatus (Error ("HTTP " <> T.pack (show status)))
    pure response
  where
    clientArgs =
      defaultSpanArguments
        { kind = Client
        , attributes = requestAttributes req
        }

-- | The request attributes recorded at span start, following the stable HTTP
-- and URL semantic conventions (see "Effectful.Tracing.SemConv").
requestAttributes :: Request -> [Attribute]
requestAttributes req =
  [ SemConv.httpRequestMethod .= decodeUtf8Lenient (method req)
  , SemConv.urlFull .= urlText req
  ]

-- | The full request URL, as the @url.full@ attribute.
urlText :: Request -> Text
urlText = T.pack . show . getUri

-- | Replace any existing @traceparent@ \/ @tracestate@ on the request with the
-- freshly-injected ones, leaving all other headers untouched.
injectHeaders :: [(HeaderName, ByteString)] -> Request -> Request
injectHeaders injected req =
  req {requestHeaders = injected <> filter (not . isTraceHeader . fst) (requestHeaders req)}
  where
    isTraceHeader h = h == traceparentHeader || h == tracestateHeader