effectful-tracing-0.1.0.0: src/Effectful/Tracing/Propagation/B3.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Effectful.Tracing.Propagation.B3
-- Description : B3 (Zipkin) 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://github.com/openzipkin/b3-propagation B3> headers that Zipkin and
-- many service meshes (Envoy, older Istio) emit. This is an alternative to the
-- W3C Trace Context propagator in "Effectful.Tracing.Propagation"; reach for it
-- when interoperating with infrastructure that speaks B3 rather than
-- @traceparent@.
--
-- Two wire encodings are supported, as in the B3 spec:
--
-- * the single @b3@ header, @{traceId}-{spanId}-{samplingState}-{parentSpanId}@
-- (the last two fields optional), which is the recommended form for new
-- deployments; and
-- * the legacy multi-header form (@X-B3-TraceId@, @X-B3-SpanId@,
-- @X-B3-Sampled@, @X-B3-Flags@, @X-B3-ParentSpanId@).
--
-- 'injectContextB3' writes the single header and 'injectContextB3Multi' the
-- multi-header form; 'extractContextB3' reads either (preferring the single
-- header when present) into a 't:SpanContext' that 'Effectful.Tracing.withRemoteParent'
-- can continue locally.
--
-- > -- server side: rejoin a B3 caller's trace
-- > handle req =
-- > case extractContextB3 (requestHeaders req) of
-- > Just parent -> withRemoteParent parent (withSpan "handle" (serve req))
-- > Nothing -> withSpan "handle" (serve req)
-- >
-- > -- client side: propagate to a B3 downstream
-- > call = withSpan "call.downstream" $ do
-- > headers <- injectContextB3
-- > liftIO (httpGet url (baseHeaders <> headers))
--
-- Like the W3C propagator, 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.B3
( -- * Single-header wire format
b3Header
-- * Multi-header wire format
, b3TraceIdHeader
, b3SpanIdHeader
, b3ParentSpanIdHeader
, b3SampledHeader
, b3FlagsHeader
-- * Outbound
, injectContextB3
, injectContextB3Multi
-- * Inbound
, extractContextB3
) where
import Data.ByteString (ByteString)
import Data.Maybe (fromMaybe)
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.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
)
-- | The single @b3@ header name (case-insensitive, per HTTP).
b3Header :: HeaderName
b3Header = "b3"
-- | The @X-B3-TraceId@ header name (multi-header form).
b3TraceIdHeader :: HeaderName
b3TraceIdHeader = "X-B3-TraceId"
-- | The @X-B3-SpanId@ header name (multi-header form).
b3SpanIdHeader :: HeaderName
b3SpanIdHeader = "X-B3-SpanId"
-- | The @X-B3-ParentSpanId@ header name. Read tolerantly but never required:
-- the library continues the caller's span (@X-B3-SpanId@) as the remote parent,
-- so the grandparent id carries no information it needs.
b3ParentSpanIdHeader :: HeaderName
b3ParentSpanIdHeader = "X-B3-ParentSpanId"
-- | The @X-B3-Sampled@ header name, carrying @1@ or @0@.
b3SampledHeader :: HeaderName
b3SampledHeader = "X-B3-Sampled"
-- | The @X-B3-Flags@ header name. A value of @1@ is B3's debug flag, which
-- implies a sampling decision of accept.
b3FlagsHeader :: HeaderName
b3FlagsHeader = "X-B3-Flags"
-- | Serialize the active span's context as a single @b3@ header for an outbound
-- request, using the full 128-bit trace id. Returns @[]@ when there is no
-- active span, so it composes with a base header list unconditionally.
injectContextB3 :: Tracer :> es => Eff es [(HeaderName, ByteString)]
injectContextB3 = maybe [] (\context -> [renderSingle context]) <$> getActiveSpan
-- | Serialize the active span's context as the legacy multi-header form
-- (@X-B3-TraceId@ \/ @X-B3-SpanId@ \/ @X-B3-Sampled@). Returns @[]@ when there
-- is no active span.
injectContextB3Multi :: Tracer :> es => Eff es [(HeaderName, ByteString)]
injectContextB3Multi = maybe [] renderMulti <$> getActiveSpan
-- | The single @b3@ header for a context.
renderSingle :: SpanContext -> (HeaderName, ByteString)
renderSingle context = (b3Header, encodeUtf8 value)
where
value =
T.intercalate
"-"
[ traceIdToHex (spanContextTraceId context)
, spanIdToHex (spanContextSpanId context)
, renderSampling (spanContextTraceFlags context)
]
-- | The multi-header list for a context.
renderMulti :: SpanContext -> [(HeaderName, ByteString)]
renderMulti context =
[ (b3TraceIdHeader, encodeUtf8 (traceIdToHex (spanContextTraceId context)))
, (b3SpanIdHeader, encodeUtf8 (spanIdToHex (spanContextSpanId context)))
, (b3SampledHeader, encodeUtf8 (renderSampling (spanContextTraceFlags context)))
]
-- | The sampling field for the sampled bit: @"1"@ when set, @"0"@ otherwise.
renderSampling :: TraceFlags -> Text
renderSampling flags = if isSampled flags then "1" else "0"
-- | Parse B3 headers from an inbound request into a 't:SpanContext' marked
-- remote. The single @b3@ header is preferred when present (and a malformed one
-- fails the extraction rather than falling through); otherwise the multi-header
-- form is read. Returns 'Nothing' when neither form yields a valid trace id and
-- span id. B3 carries no @tracestate@ equivalent, so the trace state is empty.
extractContextB3 :: [(HeaderName, ByteString)] -> Maybe SpanContext
extractContextB3 headers =
case lookup b3Header headers of
Just raw -> eitherToMaybe (decodeUtf8' raw) >>= parseSingle
Nothing -> parseMulti headers
-- | Parse a decoded single-header value. The trace and span ids are required;
-- the sampling field is optional (absent means a deferred decision, treated as
-- unsampled) and a trailing parent span id is accepted and ignored.
parseSingle :: Text -> Maybe SpanContext
parseSingle raw =
case T.splitOn "-" (T.strip raw) of
[tid, sid] -> build tid sid Nothing
[tid, sid, samp] -> build tid sid (Just samp)
[tid, sid, samp, _parent] -> build tid sid (Just samp)
_ -> Nothing
where
build tid sid msamp = do
traceId <- parseTraceId tid
spanId <- parseSpanId sid
flags <- maybe (Just deferredFlags) parseSampling msamp
validContext traceId spanId flags
-- | Parse the multi-header form. @X-B3-TraceId@ and @X-B3-SpanId@ are required;
-- the sampling decision comes from @X-B3-Flags: 1@ (debug, implies accept) or
-- @X-B3-Sampled@, defaulting to unsampled when neither is present.
parseMulti :: [(HeaderName, ByteString)] -> Maybe SpanContext
parseMulti headers = do
tid <- decoded b3TraceIdHeader
sid <- decoded b3SpanIdHeader
traceId <- parseTraceId tid
spanId <- parseSpanId sid
validContext traceId spanId multiFlags
where
decoded name = lookup name headers >>= eitherToMaybe . decodeUtf8'
multiFlags =
case (decoded b3FlagsHeader, decoded b3SampledHeader) of
(Just "1", _) -> setSampled True defaultTraceFlags
(_, Just samp) -> fromMaybe deferredFlags (parseSampling samp)
_ -> deferredFlags
-- | The flags for a deferred or absent sampling decision: unsampled, the
-- conservative choice when the caller has expressed no preference we can read.
deferredFlags :: TraceFlags
deferredFlags = defaultTraceFlags
-- | Parse a B3 sampling field. @1@ and @d@ (debug) mean accept; @0@ means deny.
parseSampling :: Text -> Maybe TraceFlags
parseSampling s
| s == "1" || s == "d" = Just (setSampled True defaultTraceFlags)
| s == "0" = Just defaultTraceFlags
| otherwise = Nothing
-- | Parse a B3 trace id, which may be 64-bit (16 hex characters) or 128-bit (32
-- hex characters). A 64-bit id is left-padded with zeros to the library's
-- fixed 128-bit width, per the B3 spec.
parseTraceId :: Text -> Maybe TraceId
parseTraceId t
| T.length t == 16 = traceIdFromHex (T.replicate 16 "0" <> t)
| otherwise = traceIdFromHex t
-- | Parse a B3 span id (16 hex characters).
parseSpanId :: Text -> Maybe SpanId
parseSpanId = spanIdFromHex
-- | 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