packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      : Effectful.Tracing.Propagation.Jaeger
-- Description : Jaeger (uber-trace-id) 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.jaegertracing.io/docs/1.21/client-libraries/#propagation-format uber-trace-id>
-- header that older Jaeger client libraries emit, plus the @uberctx-@ baggage
-- headers. This is an alternative to the W3C Trace Context propagator in
-- "Effectful.Tracing.Propagation" and the B3 propagator in
-- "Effectful.Tracing.Propagation.B3"; reach for it when interoperating with
-- infrastructure still instrumented with native Jaeger clients.
--
-- The single @uber-trace-id@ header is
-- @{trace-id}:{span-id}:{parent-span-id}:{flags}@, where the ids are hex (Jaeger
-- strips leading zeros, so they may be shorter than full width), the
-- @parent-span-id@ field is deprecated (carries no information the library
-- needs) but still required by the format, and @flags@ is a one-byte bitmask
-- whose low bit is the sampled decision. 'injectContextJaeger' writes it and
-- 'extractContextJaeger' reads it into a 't:SpanContext' that
-- 'Effectful.Tracing.withRemoteParent' can continue locally.
--
-- Baggage rides on one @uberctx-{key}: {value}@ header per item.
-- 'injectBaggageJaeger' emits those from the ambient
-- 'Effectful.Tracing.Baggage.BaggageContext', and 'extractBaggageJaeger' reads
-- them back into a 'Baggage' set. Jaeger baggage has no metadata concept, so
-- metadata is dropped on the way out and absent on the way in.
--
-- > -- server side: rejoin a Jaeger caller's trace and its baggage
-- > handle req = serveWith (extractBaggageJaeger headers) $
-- >   case extractContextJaeger headers of
-- >     Just parent -> withRemoteParent parent (withSpan "handle" (serve req))
-- >     Nothing     -> withSpan "handle" (serve req)
-- >   where headers = requestHeaders req
-- >
-- > -- client side: propagate to a Jaeger downstream
-- > call = withSpan "call.downstream" $ do
-- >   context <- injectContextJaeger
-- >   bag     <- injectBaggageJaeger
-- >   liftIO (httpGet url (baseHeaders <> context <> bag))
--
-- Like the W3C and B3 propagators, this works directly against the library's own
-- context under any interpreter that maintains an active span, with no
-- dependency on an OpenTelemetry SDK.
module Effectful.Tracing.Propagation.Jaeger
  ( -- * Header names
    uberTraceIdHeader
  , uberBaggagePrefix

    -- * Trace context
  , injectContextJaeger
  , extractContextJaeger

    -- * Baggage
  , injectBaggageJaeger
  , extractBaggageJaeger
  ) where

import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.CaseInsensitive qualified as CI
import Data.Char (digitToInt, isHexDigit)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8', encodeUtf8)
import Network.HTTP.Types.Header (HeaderName)

import Effectful (Eff, (:>))

import Effectful.Tracing.Baggage
  ( Baggage
  , BaggageContext
  , BaggageEntry (BaggageEntry)
  , baggageFromList
  , baggageToList
  , getBaggage
  )
import Effectful.Tracing.Effect (Tracer, getActiveSpan)
import Effectful.Tracing.Internal.Ids
  ( SpanId
  , TraceId
  , isValidSpanId
  , isValidTraceId
  , spanIdFromHex
  , spanIdToHex
  , traceIdFromHex
  , traceIdToHex
  )
import Effectful.Tracing.Internal.Types
  ( SpanContext (..)
  , TraceFlags
  , defaultTraceFlags
  , emptyTraceState
  , isSampled
  , setSampled
  )
import Effectful.Tracing.Propagation.Baggage (maxBaggageEntries)

-- | The @uber-trace-id@ header name (case-insensitive, per HTTP).
uberTraceIdHeader :: HeaderName
uberTraceIdHeader = "uber-trace-id"

-- | The prefix Jaeger puts on each per-item baggage header, @uberctx-@. A header
-- named @uberctx-{key}@ carries the baggage value for @{key}@.
uberBaggagePrefix :: ByteString
uberBaggagePrefix = "uberctx-"

-- | Serialize the active span's context as a single @uber-trace-id@ header for an
-- outbound request, using the full 128-bit trace id and a @0@ parent field.
-- Returns @[]@ when there is no active span, so it composes with a base header
-- list unconditionally.
injectContextJaeger :: Tracer :> es => Eff es [(HeaderName, ByteString)]
injectContextJaeger = maybe [] (\context -> [renderUberTraceId context]) <$> getActiveSpan

-- | Serialize the ambient baggage as @uberctx-{key}@ headers, one per entry
-- (ordered by key). Metadata is dropped, as Jaeger baggage has no equivalent.
-- Returns @[]@ when the baggage is empty.
injectBaggageJaeger :: BaggageContext :> es => Eff es [(HeaderName, ByteString)]
injectBaggageJaeger = map renderItem . baggageToList <$> getBaggage
  where
    renderItem (key, BaggageEntry value _metadata) =
      (CI.mk (uberBaggagePrefix <> encodeUtf8 key), encodeUtf8 value)

-- | The @uber-trace-id@ header for a context.
renderUberTraceId :: SpanContext -> (HeaderName, ByteString)
renderUberTraceId context = (uberTraceIdHeader, encodeUtf8 value)
  where
    value =
      T.intercalate
        ":"
        [ traceIdToHex (spanContextTraceId context)
        , spanIdToHex (spanContextSpanId context)
        , "0"
        , renderFlags (spanContextTraceFlags context)
        ]
    renderFlags flags = if isSampled flags then "1" else "0"

-- | Parse an inbound @uber-trace-id@ header into a 't:SpanContext' marked remote.
-- Returns 'Nothing' when the header is absent, not decodable, or malformed.
-- Jaeger carries no @tracestate@ equivalent, so the trace state is empty.
extractContextJaeger :: [(HeaderName, ByteString)] -> Maybe SpanContext
extractContextJaeger headers =
  lookup uberTraceIdHeader headers >>= eitherToMaybe . decodeUtf8' >>= parseUberTraceId

-- | Read @uberctx-@ baggage headers into a 'Baggage' set, skipping any whose key
-- or value is empty or not decodable, and capping at the W3C entry limit so a
-- flood of headers cannot grow the set without bound. Metadata is always absent.
extractBaggageJaeger :: [(HeaderName, ByteString)] -> Baggage
extractBaggageJaeger = baggageFromList . take maxBaggageEntries . mapMaybe item
  where
    item (name, rawValue) = do
      keyBytes <- BS.stripPrefix uberBaggagePrefix (CI.foldedCase name)
      key <- T.strip <$> eitherToMaybe (decodeUtf8' keyBytes)
      value <- T.strip <$> eitherToMaybe (decodeUtf8' rawValue)
      if T.null key then Nothing else Just (key, BaggageEntry value Nothing)

-- | Parse a decoded @uber-trace-id@ value. The format requires exactly four
-- colon-separated fields; the trace and span ids must be valid hex, the parent
-- field is accepted and ignored, and the flags field's low bit is the sampled
-- decision.
parseUberTraceId :: Text -> Maybe SpanContext
parseUberTraceId raw =
  case T.splitOn ":" (T.strip raw) of
    [tid, sid, _parent, flags] -> do
      traceId <- parseTraceId tid
      spanId <- parseSpanId sid
      traceFlags <- parseFlags flags
      validContext traceId spanId traceFlags
    _ -> Nothing

-- | Parse a Jaeger trace id. Jaeger may strip leading zeros and may use a 64-bit
-- (16-hex) or 128-bit (32-hex) id, so any non-empty hex string up to 32
-- characters is left-padded with zeros to the library's fixed 128-bit width.
parseTraceId :: Text -> Maybe TraceId
parseTraceId t
  | T.null t || T.length t > 32 = Nothing
  | otherwise = traceIdFromHex (T.justifyRight 32 '0' t)

-- | Parse a Jaeger span id, left-padding a leading-zero-stripped value back to
-- the full 64-bit (16-hex) width.
parseSpanId :: Text -> Maybe SpanId
parseSpanId t
  | T.null t || T.length t > 16 = Nothing
  | otherwise = spanIdFromHex (T.justifyRight 16 '0' t)

-- | Parse the one-byte flags field (a hex bitmask) and read its low bit as the
-- sampled decision; the debug and firehose bits have no home in the library's
-- one-bit 'TraceFlags' and are ignored. A non-hex or empty field is rejected.
parseFlags :: Text -> Maybe TraceFlags
parseFlags t
  | T.null t || not (T.all isHexDigit t) = Nothing
  | otherwise =
      let sampled = odd (T.foldl' (\acc c -> acc * 16 + digitToInt c) (0 :: Int) t)
       in Just (if sampled then setSampled True defaultTraceFlags else defaultTraceFlags)

-- | Assemble a remote 't:SpanContext' once the ids are confirmed non-zero and
-- correctly sized.
validContext :: TraceId -> SpanId -> TraceFlags -> Maybe SpanContext
validContext traceId spanId flags
  | isValidTraceId traceId && isValidSpanId spanId =
      Just
        SpanContext
          { spanContextTraceId = traceId
          , spanContextSpanId = spanId
          , spanContextTraceFlags = flags
          , spanContextTraceState = emptyTraceState
          , spanContextIsRemote = True
          }
  | otherwise = Nothing

-- | Collapse a decode 'Either' to 'Maybe', discarding the error.
eitherToMaybe :: Either e a -> Maybe a
eitherToMaybe = either (const Nothing) Just