effectful-tracing

Tracing as a scoped effect for Haskell, built natively on
effectful, with
OpenTelemetry interop via
hs-opentelemetry.
A span is a scoped, higher-order effect. That makes "the current span" lexical
instead of thread-local, which removes a whole class of context-loss bugs and
keeps the API clean. The library does not reimplement the OpenTelemetry wire
format: it compiles down to hs-opentelemetry for real export and ships several
other interpreters (no-op, in-memory, pretty-print) for testing and development.
Why this over hs-opentelemetry directly?
If you are already on effectful, this library is the more natural fit. The
differences are about where the seams sit, not about what gets exported:
- The current span is lexical, not thread-local.
hs-opentelemetry tracks
the active span through an implicit context that you propagate by hand across
thread and async boundaries. Here a span is a scoped higher-order effect, so
"the current span" is exactly the lexically enclosing withSpan and the
compiler tracks it for you. That removes the most common source of orphaned or
mis-parented spans.
- The backend is an interpreter you choose at the call site. The same
Tracer-using code runs under the no-op, in-memory, pretty-print, or
OpenTelemetry interpreter with no change. You get a real trace tree on stderr
in development and assertable spans in tests without standing up a collector,
and you swap in runTracerOTel for production.
- It composes as an
effectful effect. Tracer sits alongside your other
effects with an ordinary Tracer :> es constraint, rather than threading a
reader of OpenTelemetry context through your stack.
It is not a reimplementation of the wire format: real export still goes through
hs-opentelemetry-sdk, and this library's ids and sampler stay the source of
truth. If you are not using effectful, depending on hs-opentelemetry
directly is the simpler choice.
Status: first release (0.1.0.0) is on Hackage. The interpreters
(no-op, in-memory, pretty-print, OpenTelemetry); W3C Trace Context, B3, and
Jaeger propagation (composable, and configurable from OTEL_ environment
variables); sampling; span limits; async context propagation; baggage; a
log-correlation bridge; in-test assertions; and the instrumentation helpers for
WAI, http-client, Servant, databases (postgresql-simple, sqlite-simple,
valiant), and message queues (with a RabbitMQ binding over amqp) have all
landed.
Install
Add effectful-tracing to your project's dependencies. In a .cabal file:
build-depends:
, effectful-tracing >=0.1 && <0.2
The base package brings in only effectful and a small set of core
dependencies. The integrations (OpenTelemetry export, the WAI / http-client /
Servant helpers, the database driver bindings, and the RabbitMQ binding) each
live behind a cabal flag that is off by default, so nothing pulls in a web,
database, or OpenTelemetry stack unless you ask for it. Turn a flag on by naming
it in cabal.project:
package effectful-tracing
flags: +otel +wai +http-client
The available flags are otel, wai, http-client, servant,
postgresql-simple, sqlite-simple, valiant, amqp, and secure-ids. The
framework-agnostic database and messaging cores
(Effectful.Tracing.Instrumentation.Database and .Messaging) are always
built and need no flag.
Quick start
Write a computation against the Tracer effect, then discharge it. The no-op
interpreter (runTracerNoOp) satisfies the effect with zero tracing and no
external dependencies, so this runs as-is. Swap in the in-memory, pretty-print,
or OpenTelemetry interpreter without touching the computation.
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Data.Text (Text)
import Effectful (Eff, runEff, (:>))
import Effectful.Tracing
-- A computation that uses tracing without committing to a backend.
compute :: Tracer :> es => Eff es Int
compute = withSpan "outer" $ do
addAttribute "user.id" ("u123" :: Text)
total <- withSpan "inner" $ do
addEvent "fetching" []
pure 42
setStatus Ok
pure total
main :: IO ()
main = do
result <- runEff (runTracerNoOp compute)
print result
Seeing your traces
During development, swap the no-op interpreter for the pretty-print one to see
the trace as a tree on stderr. The computation does not change, only the
interpreter.
import Effectful.Tracing.Interpreter.PrettyPrint
import System.IO (stderr)
main :: IO ()
main = do
result <- runEff (runTracerPretty (defaultPrettyPrintConfig stderr) compute)
print result
prints:
trace 4f1a9c000000000000000000000000aa (1ms)
└─ outer (1ms) status=Ok
user.id=u123
└─ inner (0ms) status=Ok
event: fetching @ +0.0ms
(The trace id and durations vary from run to run.) For tests, the in-memory
interpreter (Effectful.Tracing.Interpreter.InMemory) captures completed spans
into a buffer you can assert on.
Instrumenting a web service
A few optional helpers cover the common server seams. Each is behind a cabal flag
(off by default), so the base package never pulls in a web stack:
Effectful.Tracing.Instrumentation.Wai (flag wai): a Middleware that opens
a server span per request and continues an inbound distributed trace.
Effectful.Tracing.Instrumentation.HttpClient (flag http-client): a wrapper
that opens a client span and injects the trace context into outbound
requests.
Effectful.Tracing.Instrumentation.Servant (flag servant): a per-endpoint
combinator plus middleware that names server spans "{method} {route}" with
the matched route template recorded as http.route.
Enable them when depending on the package:
build-depends: effectful-tracing
-- in cabal.project, or via --flags on the command line:
-- --flags="wai http-client"
On the inbound side, wrap your application with traceMiddleware. Because WAI
runs in IO but the Tracer effect lives in Eff, the middleware takes an
unlift function from effectful's withEffToIO. A real server handles requests
concurrently, so use a concurrent unlift strategy:
import Effectful
import Effectful.Tracing (Tracer)
import Effectful.Tracing.Instrumentation.Wai (traceMiddleware)
import Network.Wai (Application)
import Network.Wai.Handler.Warp qualified as Warp
runServer :: (IOE :> es, Tracer :> es) => Application -> Eff es ()
runServer app =
withEffToIO (ConcUnlift Persistent Unlimited) $ \runInIO ->
Warp.run 8080 (traceMiddleware runInIO app)
traceMiddleware names each server span after the request method (GET,
POST). When your router knows the matched route template, traceMiddlewareWith
lets you name spans "{method} {route}" instead. See the cookbook recipe "Name
server spans by route, not just method".
On the outbound side, call downstream services through httpLbsTraced. It opens
a client span and writes traceparent / tracestate into the request, so the
next service continues the same trace:
import Control.Monad.IO.Class (liftIO)
import Effectful (Eff, IOE, (:>))
import Effectful.Tracing (Tracer, withSpan)
import Effectful.Tracing.Instrumentation.HttpClient (httpLbsTraced)
import Network.HTTP.Client (Manager, Response, parseRequest)
import Data.ByteString.Lazy (ByteString)
fetchWidget :: (IOE :> es, Tracer :> es) => Manager -> Eff es (Response ByteString)
fetchWidget manager = withSpan "load.widgets" $ do
req <- liftIO (parseRequest "https://widgets.internal/widgets")
httpLbsTraced req manager
Both helpers speak W3C Trace Context (Effectful.Tracing.Propagation), so a
server span opened by the middleware and a client span opened by the wrapper
join into one distributed trace across the hop. To make a downstream call nest
under a specific request, run that request's handler in Eff so the server span
is the active span when httpLbsTraced runs. For a complete, runnable version of
exactly this wiring, see examples/servant-app, a
two-endpoint Servant service whose inbound and outbound spans join into one trace
in Jaeger.
Instrumenting databases and message queues
The same scoped-span approach covers the database client side and message queues,
through framework-agnostic cores that are always built (no flag, no extra
dependencies):
Effectful.Tracing.Instrumentation.Database: describe a call with a
DatabaseQuery and run it inside withQuerySpan, which opens a client span
named "{operation} {collection}" with the stable db.* attributes. Thin
driver bindings layer on top behind their own flags: postgresql-simple,
sqlite-simple, and valiant
(the compile-time checked PostgreSQL library), each a drop-in for the driver's
own runners.
Effectful.Tracing.Instrumentation.Messaging: describe a publish or consume
with a MessagingOperation and run it inside withMessagingSpan, which picks
the span kind from the operation (producer / consumer / client) and
records the messaging.* conventions. injectMessageHeaders and
withConsumerSpan carry the trace across the broker through message headers. A
RabbitMQ binding layers on top behind the amqp flag
(Effectful.Tracing.Instrumentation.Amqp): publishMsgTraced, getMsgTraced,
and withProcessSpan do the header plumbing over the amqp client for you.
See the cookbook recipes "Trace a database query" and "Trace a message producer
and consumer" for the code, and examples/order-pipeline
for a runnable version: a producer and consumer that join into one distributed
trace across RabbitMQ, with the consumer writing to PostgreSQL inside the
continued trace, all brought up with docker compose.
Exporting to OpenTelemetry
Build with the otel flag and discharge the effect with runTracerOTel
(Effectful.Tracing.Interpreter.OpenTelemetry). It keeps this library's ids and
sampler as the source of truth and translates each finished span into an
hs-opentelemetry span for the SpanProcessors you supply, so you can point it
at any OTLP collector (Jaeger, Tempo, the OpenTelemetry Collector). The
tutorial walks through wiring the OTLP exporter and bringing
up a local Jaeger with docker compose.
Troubleshooting
My spans come out flat instead of nested. A span nests under another only
when the outer one is the active span at the point the inner withSpan runs,
and "active" is lexical. The usual cause is a boundary where the code drops back
into plain IO: for example a WAI Application or a callback that runs outside
the Eff scope cannot see a Tracer span opened in Eff. Run the handler in
Eff (through the unlift you passed to the middleware) so the server span is
still the active span when the inner work runs. See "Instrumenting a web
service" above.
An outbound call starts its own trace instead of joining the request's.
httpLbsTraced opens a client span as a child of whatever span is active when
it runs. If no span is active (the call happens after the server span's scope
has closed, or in a thread that never entered Eff), it has nothing to attach
to and begins a fresh root trace. Make the call inside the request handler's
Eff scope, with the server span still open, so the client span becomes its
child and the traceparent header continues the same trace downstream.
Nothing shows up in my collector (or in pretty-print output). Check, in
order: (1) the interpreter. runTracerNoOp records nothing by design. Use
runTracerPretty, the in-memory interpreter, or runTracerOTel. (2) The flag.
runTracerOTel only exists when the package is built with +otel, and the WAI
/ http-client / Servant helpers need their flags too. A missing flag means the
module is not in scope. (3) The sampler. A low sampling ratio drops most spans
before export. Set the sampler to always-on while you are confirming the
pipeline works, then dial it back. (4) For OpenTelemetry export specifically,
that you actually supplied a SpanProcessor/exporter pointed at your collector;
runTracerOTel exports through the processors you give it and nowhere else.
Trace context is not crossing a message broker. Headers only carry the
context if you inject on publish and extract on consume. Use
injectMessageHeaders (or the amqp binding's publishMsgTraced) when sending
and withConsumerSpan / withProcessSpan when receiving. A consumer that opens
a plain withSpan instead will start a new trace rather than continuing the
producer's.
Learning more
docs/tutorial.md: a guided walkthrough from a trace on
your terminal to OpenTelemetry export, in about fifteen minutes.
docs/cookbook.md: short recipes for everyday tasks
(trace an existing function, sampling, connecting HTTP traces, database queries,
message producers and consumers, workers).
docs/design.md: how the library is designed, organized by
concept (the data model, the Tracer effect, the lifecycle, sampling,
propagation, OTel export). Start here to understand the internals.
examples/servant-app: an end-to-end Servant service
whose inbound and outbound spans join into one distributed trace in Jaeger.
examples/order-pipeline: a two-process order
pipeline (RabbitMQ producer and consumer, PostgreSQL writes) whose spans join
into one trace across the broker, brought up with docker compose.
examples/local-dev: two small programs that need no
collector: a worker loop (one span per job, interpreter chosen at runtime,
error recording, a linked background trace) and a custom-sampler demo.
Supported GHC
- GHC 9.6.7
- GHC 9.8.4
- GHC 9.10.3
License
BSD-3-Clause. See LICENSE.