packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Propagation.Baggage
-- Description : W3C Baggage propagation across process boundaries.
-- Copyright   : (c) The effectful-tracing contributors
-- License     : BSD-3-Clause
-- Stability   : experimental
--
-- Carry ambient key-value context across a network hop using the
-- <https://www.w3.org/TR/baggage/ W3C Baggage> @baggage@ header. 'injectBaggage'
-- renders the in-scope baggage for an outbound request; 'extractBaggage' parses
-- an inbound header back into a 't:Baggage' value, which
-- 'Effectful.Tracing.Baggage.runBaggageWith' then makes ambient for the request.
--
-- > -- inbound: seed the ambient baggage from the request
-- > handle req = runBaggageWith (extractBaggage (requestHeaders req)) (serve req)
-- >
-- > -- outbound: forward the ambient baggage to the next hop
-- > call = do
-- >   headers <- injectBaggage
-- >   liftIO (httpGet url (baseHeaders <> headers))
--
-- The wire format is @key1=value1,key2=value2;prop=x@: comma-separated entries,
-- each an optionally percent-encoded value with optional @;@-separated metadata.
-- Values are percent-encoded on the way out and decoded on the way in; keys are
-- emitted verbatim. Parsing is resilient: a malformed entry is skipped rather
-- than failing the whole header, and the entry count is capped at
-- 'maxBaggageEntries'.
--
-- This is built directly against the 'Effectful.Tracing.Baggage.BaggageContext'
-- effect, with no dependency on an OpenTelemetry SDK.
module Effectful.Tracing.Propagation.Baggage
  ( -- * Wire format
    baggageHeader
  , maxBaggageEntries

    -- * Outbound
  , injectBaggage
  , renderBaggage

    -- * Inbound
  , extractBaggage
  , parseBaggage
  ) where

import Data.Bits (shiftL, shiftR, (.&.), (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Char (isAsciiLower, isAsciiUpper, isDigit, ord)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8', decodeUtf8Lenient, encodeUtf8)
import Data.Word (Word8)
import Network.HTTP.Types.Header (HeaderName)

import Effectful (Eff, (:>))

import Effectful.Tracing.Baggage
  ( Baggage
  , BaggageContext
  , BaggageEntry (BaggageEntry)
  , baggageFromList
  , baggageToList
  , getBaggage
  )

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

-- | The maximum number of entries parsed from a single header, per the W3C
-- limit. Extra entries beyond this are dropped.
maxBaggageEntries :: Int
maxBaggageEntries = 180

-- | Serialize the in-scope baggage as a @baggage@ header for an outbound
-- request. Returns @[]@ when the baggage is empty, so it composes with a base
-- header list unconditionally.
injectBaggage :: BaggageContext :> es => Eff es [(HeaderName, ByteString)]
injectBaggage = do
  rendered <- renderBaggage <$> getBaggage
  pure [(baggageHeader, encodeUtf8 rendered) | not (T.null rendered)]

-- | Render baggage to a header value: @key=value@ entries (values
-- percent-encoded, metadata appended verbatim) joined with commas, ordered by
-- key. Empty baggage renders to the empty string.
renderBaggage :: Baggage -> Text
renderBaggage = T.intercalate "," . map renderMember . baggageToList
  where
    renderMember (key, BaggageEntry value metadata) =
      key <> "=" <> percentEncode value <> maybe "" (";" <>) metadata

-- | Parse the @baggage@ header from an inbound request into a 'Baggage' value.
-- An absent or undecodable header yields empty baggage.
extractBaggage :: [(HeaderName, ByteString)] -> Baggage
extractBaggage headers =
  case lookup baggageHeader headers of
    Nothing -> baggageFromList []
    Just raw -> either (const (baggageFromList [])) parseBaggage (decodeUtf8' raw)

-- | Parse a decoded @baggage@ header value. Total: malformed entries (no key,
-- an invalid key token) are skipped, surrounding whitespace is trimmed, values
-- are percent-decoded, and the result is capped at 'maxBaggageEntries'.
parseBaggage :: Text -> Baggage
parseBaggage =
  baggageFromList . take maxBaggageEntries . mapMaybe parseMember . T.splitOn ","

-- | Parse one @key=value;props@ member, or 'Nothing' if it is malformed.
parseMember :: Text -> Maybe (Text, BaggageEntry)
parseMember member =
  case T.splitOn ";" (T.strip member) of
    [] -> Nothing
    (keyValue : props) -> do
      let (rawKey, rest) = T.breakOn "=" keyValue
          key = T.strip rawKey
      _ <- T.stripPrefix "=" rest -- require a '=' to be present
      if not (isToken key)
        then Nothing
        else
          let value = percentDecode (T.strip (T.drop 1 rest))
              metadata = case map T.strip props of
                [] -> Nothing
                stripped -> Just (T.intercalate ";" stripped)
           in Just (key, BaggageEntry value metadata)

-- | Whether a key is a non-empty RFC 7230 token (the W3C key grammar).
isToken :: Text -> Bool
isToken key = not (T.null key) && T.all isTokenChar key

-- | An RFC 7230 @tchar@: an unreserved token character.
isTokenChar :: Char -> Bool
isTokenChar c =
  isAsciiLower c
    || isAsciiUpper c
    || isDigit c
    || c `elem` ("!#$%&'*+-.^_`|~" :: String)

-- | Percent-encode a value: keep RFC 3986 unreserved bytes, escape the rest as
-- @%XX@ (uppercase hex) over the UTF-8 encoding. Conservative (it escapes more
-- than the W3C grammar strictly requires), but always produces a valid value.
percentEncode :: Text -> Text
percentEncode = T.concat . map encodeByte . BS.unpack . encodeUtf8
  where
    encodeByte w
      | isUnreserved w = T.singleton (toEnum (fromIntegral w))
      | otherwise = T.pack ['%', hexDigit (w `shiftR` 4), hexDigit (w .&. 0x0F)]

-- | The uppercase hex digit for a nibble (0-15).
hexDigit :: Word8 -> Char
hexDigit n
  | n < 10 = toEnum (fromIntegral n + ord '0')
  | otherwise = toEnum (fromIntegral n - 10 + ord 'A')

-- | An RFC 3986 unreserved byte: @ALPHA@, @DIGIT@, or one of @-._~@.
isUnreserved :: Word8 -> Bool
isUnreserved w =
  (w >= 0x41 && w <= 0x5A) -- A-Z
    || (w >= 0x61 && w <= 0x7A) -- a-z
    || (w >= 0x30 && w <= 0x39) -- 0-9
    || w == 0x2D -- '-'
    || w == 0x2E -- '.'
    || w == 0x5F -- '_'
    || w == 0x7E -- '~'

-- | Percent-decode a value. Total: a @%@ not followed by two hex digits is kept
-- literally. Decodes over the UTF-8 bytes, so multi-byte values round-trip.
percentDecode :: Text -> Text
percentDecode = decodeUtf8Lenient . BS.pack . decodeBytes . BS.unpack . encodeUtf8
  where
    decodeBytes (w : a : b : rest)
      | w == 0x25 -- '%'
      , Just hi <- hexValue a
      , Just lo <- hexValue b =
          ((hi `shiftL` 4) .|. lo) : decodeBytes rest
    decodeBytes (w : rest) = w : decodeBytes rest
    decodeBytes [] = []

-- | The numeric value of an ASCII hex-digit byte, if it is one.
hexValue :: Word8 -> Maybe Word8
hexValue w
  | w >= 0x30 && w <= 0x39 = Just (w - 0x30) -- 0-9
  | w >= 0x41 && w <= 0x46 = Just (w - 0x41 + 10) -- A-F
  | w >= 0x61 && w <= 0x66 = Just (w - 0x61 + 10) -- a-f
  | otherwise = Nothing