packages feed

effectful-tracing-0.1.0.0: src/Effectful/Tracing/Propagation.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Propagation
-- Description : W3C Trace Context propagation across process boundaries.
-- Copyright   : (c) The effectful-tracing contributors
-- License     : BSD-3-Clause
-- Stability   : experimental
--
-- Carry a trace across a network hop using the
-- <https://www.w3.org/TR/trace-context/ W3C Trace Context> @traceparent@ and
-- @tracestate@ headers. 'injectContext' serializes the active span for an
-- outbound request; 'extractContext' parses the headers from an inbound request
-- into a 't:SpanContext', which 'withRemoteParent' then continues as a local
-- trace.
--
-- > -- server side: rejoin the caller's trace
-- > handle req =
-- >   case extractContext (requestHeaders req) of
-- >     Just parent -> withRemoteParent parent (withSpan "handle" (serve req))
-- >     Nothing     -> withSpan "handle" (serve req)
-- >
-- > -- client side: propagate to the next hop
-- > call = withSpan "call.downstream" $ do
-- >   headers <- injectContext
-- >   liftIO (httpGet url (baseHeaders <> headers))
--
-- This implements propagation directly against the library's own context, with
-- no dependency on an OpenTelemetry SDK, so it works under any interpreter that
-- maintains an active span (in-memory, pretty-print, OpenTelemetry).
module Effectful.Tracing.Propagation
  ( -- * Wire format
    traceparentHeader
  , tracestateHeader

    -- * Outbound
  , injectContext

    -- * Inbound
  , extractContext
  , withRemoteParent
  ) where

import Data.ByteString (ByteString)
import Data.Char (isHexDigit)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8', encodeUtf8)
import Network.HTTP.Types.Header (HeaderName)
import Numeric (readHex, showHex)

import Effectful (Eff, (:>))

import Effectful.Tracing.Effect (Tracer, getActiveSpan, withRemoteParent)
import Effectful.Tracing.Internal.Ids
  ( isValidSpanId
  , isValidTraceId
  , spanIdFromHex
  , spanIdToHex
  , traceIdFromHex
  , traceIdToHex
  )
import Effectful.Tracing.Internal.Types
  ( SpanContext (..)
  , TraceFlags (TraceFlags)
  , TraceState
  , emptyTraceState
  , traceStateFromHeader
  , traceStateToHeader
  )

-- | The @traceparent@ header name (case-insensitive, per HTTP).
traceparentHeader :: HeaderName
traceparentHeader = "traceparent"

-- | The @tracestate@ header name.
tracestateHeader :: HeaderName
tracestateHeader = "tracestate"

-- | Serialize the active span's context as @traceparent@ (and @tracestate@, if
-- non-empty) headers for an outbound request. Returns @[]@ when there is no
-- active span, so it composes with a base header list unconditionally.
injectContext :: Tracer :> es => Eff es [(HeaderName, ByteString)]
injectContext = maybe [] contextToHeaders <$> getActiveSpan

-- | The header list for a specific context.
contextToHeaders :: SpanContext -> [(HeaderName, ByteString)]
contextToHeaders context =
  (traceparentHeader, encodeUtf8 (renderTraceparent context))
    : [ (tracestateHeader, encodeUtf8 rendered)
      | let rendered = traceStateToHeader (spanContextTraceState context)
      , not (T.null rendered)
      ]

-- | Render a @traceparent@ value: @version-traceid-spanid-flags@, all lowercase
-- hex, version pinned to @00@.
renderTraceparent :: SpanContext -> Text
renderTraceparent context =
  T.intercalate
    "-"
    [ "00"
    , traceIdToHex (spanContextTraceId context)
    , spanIdToHex (spanContextSpanId context)
    , flagsToHex (spanContextTraceFlags context)
    ]

-- | A 't:TraceFlags' byte as two lowercase hex digits.
flagsToHex :: TraceFlags -> Text
flagsToHex (TraceFlags w) = T.justifyRight 2 '0' (T.pack (showHex w ""))

-- | Parse @traceparent@ / @tracestate@ headers from an inbound request into a
-- 't:SpanContext' marked remote. Returns 'Nothing' if @traceparent@ is absent or
-- malformed (an unparsable @tracestate@ is treated as empty rather than failing
-- the whole extraction, per the spec's resilience guidance). Header lookup is
-- case-insensitive because 'HeaderName' is case-insensitive.
extractContext :: [(HeaderName, ByteString)] -> Maybe SpanContext
extractContext headers = do
  rawTraceparent <- lookup traceparentHeader headers
  traceparent <- either (const Nothing) Just (decodeUtf8' rawTraceparent)
  parseTraceparent traceparent traceState
  where
    traceState = case lookup tracestateHeader headers of
      Just raw -> either (const emptyTraceState) traceStateFromHeader (decodeUtf8' raw)
      Nothing -> emptyTraceState

-- | Parse a decoded @traceparent@ value, attaching the already-parsed trace
-- state. Future versions are accepted by reading the first four fields; version
-- @00@ must have exactly four fields, and the all-zero ids are rejected.
parseTraceparent :: Text -> TraceState -> Maybe SpanContext
parseTraceparent raw traceState =
  case T.splitOn "-" (T.strip raw) of
    (version : tid : sid : flags : rest)
      | validVersion version
      , version /= "00" || null rest -> do
          traceId <- traceIdFromHex tid
          spanId <- spanIdFromHex sid
          if isValidTraceId traceId && isValidSpanId spanId
            then do
              flagsByte <- parseFlags flags
              Just
                SpanContext
                  { spanContextTraceId = traceId
                  , spanContextSpanId = spanId
                  , spanContextTraceFlags = flagsByte
                  , spanContextTraceState = traceState
                  , spanContextIsRemote = True
                  }
            else Nothing
    _ -> Nothing

-- | A version field is two hex digits and not the reserved @ff@.
validVersion :: Text -> Bool
validVersion v = T.length v == 2 && T.all isHexDigit v && v /= "ff"

-- | Parse the two-hex-digit flags field into a 't:TraceFlags' byte.
parseFlags :: Text -> Maybe TraceFlags
parseFlags t
  | T.length t == 2
  , T.all isHexDigit t
  , [(n, "")] <- readHex (T.unpack t) =
      Just (TraceFlags (fromIntegral (n :: Int)))
  | otherwise = Nothing