README.md
May 27, 2026 · View on GitHub
OpenTelemetry for Haskell
Traces, metrics, and logs for Haskell applications and libraries
In Brief
hs-opentelemetry is a native Haskell implementation of OpenTelemetry, the vendor-neutral observability standard backed by the CNCF. It lets you instrument your Haskell code to emit
- Traces - distributed request flows across services
- Metrics - counters, histograms, and gauges
- Logs - structured log records correlated with traces
and export them to any OpenTelemetry-compatible backend (Jaeger, Honeycomb, Datadog, Grafana, etc.) without coupling your code to a specific vendor.
The project follows the upstream OpenTelemetry specification closely, with a clean separation between the API (for library authors) and the SDK (for application authors) - the same split used by the official Go, Python, and Java implementations.
Why Instrument with OpenTelemetry?
If you've ever added putStrLn-based debugging to track down why a request was
slow, or scattered ad-hoc metrics across your codebase, you've felt the problem
OpenTelemetry solves.
Without OpenTelemetry, observability in Haskell tends to look like:
handleRequest req = do
t0 <- getCurrentTime
putStrLn $ "Processing " <> show (requestPath req)
result <- processRequest req
t1 <- getCurrentTime
putStrLn $ "Done in " <> show (diffUTCTime t1 t0)
pure result
This doesn't compose. It doesn't correlate across services. It doesn't let you switch from stdout to Datadog to Honeycomb without rewriting your code. And it pollutes your business logic with observability concerns.
With hs-opentelemetry, the same intent becomes:
handleRequest req =
inSpan tracer "handleRequest" defaultSpanArguments $ do
processRequest req
One line. The span carries timing, a unique trace ID that correlates across service boundaries, and you can attach structured attributes to it. The SDK decides where the data goes - stdout in development, OTLP to your collector in production - and your application code doesn't change.
Getting Started
There are two packages to know about:
| You are... | Use |
|---|---|
| Instrumenting a library (e.g., a database driver, HTTP client wrapper) | hs-opentelemetry-api |
| Building an application that configures and exports telemetry | hs-opentelemetry-sdk |
Library authors depend on the API so their users aren't forced into a particular SDK configuration. Application authors pull in the SDK, which initializes providers, installs exporters, and wires everything together.
Traces
Traces represent the path of a request through your system. Each unit of work is a span; spans nest to form a tree.
import OpenTelemetry.Trace (withTracerProvider, getTracer, tracerOptions)
import OpenTelemetry.Trace.Core (inSpan, defaultSpanArguments)
main :: IO ()
main = withTracerProvider $ \tp -> do
let tracer = getTracer tp "my-service" tracerOptions
inSpan tracer "main" defaultSpanArguments $ do
inSpan tracer "step-1" defaultSpanArguments $
putStrLn "doing work"
inSpan tracer "step-2" defaultSpanArguments $
putStrLn "more work"
withTracerProvider reads standard OTEL_* environment variables (service
name, exporter endpoint, sampling rate, etc.), initializes the global provider,
and shuts it down cleanly on exit - including flushing any buffered spans.
Use inSpan' when you need access to the Span handle, for example to attach
attributes during execution:
inSpan' tracer "fetchUser" defaultSpanArguments $ \span -> do
user <- lookupUser uid
addAttribute span "user.id" (toAttribute uid)
pure user
Metrics
Metrics capture measurements over time: request counts, latencies, queue depths.
import OpenTelemetry.Metric.Core
main :: IO ()
main = do
mp <- getGlobalMeterProvider
meter <- getMeter mp "my-service"
counter <- meterCreateCounterInt64 meter
"http.requests" "" Nothing defaultAdvisoryParameters
latency <- meterCreateHistogram meter
"http.request.duration" "ms" Nothing defaultAdvisoryParameters
-- In your request handler:
counterAdd counter 1 [("method", toAttribute ("GET" :: Text))]
histogramRecord latency 42.5 [("method", toAttribute ("GET" :: Text))]
The SDK supports synchronous instruments (counters, histograms, up-down counters) and asynchronous/observable instruments for system-level metrics like GHC runtime statistics:
import OpenTelemetry.Instrumentation.GHCMetrics (registerGHCMetrics)
meter <- getMeter mp "ghc-metrics"
registerGHCMetrics meter
-- GC pause times, allocation rates, thread counts, etc. are now exported
Logs
The logging API is a bridge: it lets existing Haskell logging libraries (katip, co-log, monad-logger) emit structured log records that are automatically correlated with the active trace context.
import OpenTelemetry.Log (withLoggerProvider, makeLogger)
import OpenTelemetry.Log.Core (emitLogRecord, LogRecordArguments(..), SeverityNumber(..))
main :: IO ()
main = withLoggerProvider $ \lp -> do
let logger = makeLogger lp "my-app"
emitLogRecord logger $ emptyLogRecordArguments
{ body = Just (toValue ("Application started" :: Text))
, severityNumber = Just SeverityNumberInfo
}
Or use one of the bridge libraries to connect your existing logging framework:
| Logger | Bridge |
|---|---|
| katip | hs-opentelemetry-instrumentation-katip |
| co-log | hs-opentelemetry-instrumentation-co-log |
| monad-logger | hs-opentelemetry-instrumentation-monad-logger |
WAI Middleware
For web applications, a single line of middleware instruments every incoming HTTP request:
import Network.Wai.Handler.Warp (run)
import OpenTelemetry.Instrumentation.Wai (newOpenTelemetryWaiMiddleware)
import OpenTelemetry.Trace (withTracerProvider)
main :: IO ()
main = withTracerProvider $ \_ -> do
otelMiddleware <- newOpenTelemetryWaiMiddleware
run 8080 $ otelMiddleware myApp
Each request gets a server span with method, route, status code, and timing. Downstream calls (database queries, HTTP clients) automatically nest as child spans when you use the corresponding instrumentation libraries.
Specification Conformance
Traces, metrics, and logs are fully implemented. See the detailed conformance checklist for per-feature coverage against the OpenTelemetry specification.
| Signal | API Module | SDK Module | Status |
|---|---|---|---|
| Traces | OpenTelemetry.Trace.Core | OpenTelemetry.Trace | Stable |
| Metrics | OpenTelemetry.Metric.Core | OpenTelemetry.MeterProvider | Stable |
| Logs | OpenTelemetry.Log.Core | OpenTelemetry.Log | Stable |
Performance
The library is designed for minimal overhead in instrumented applications. When
the SDK is not installed or has no processors configured, inSpan is a no-op
that costs 13.6 ns and allocates 15 bytes.
Benchmarks (GHC 9.10, aarch64-osx, -O1 -N1 -A32m):
| Operation | Time | Allocated |
|---|---|---|
inSpan no-op (no SDK) | 13.6 ns | 15 B |
inSpan active | 218–445 ns | 1.2–2.5 KB |
| bare span (create+end) | 209 ns | 1.2 KB |
| HTTP span (3 attrs) | 410 ns | 2.5 KB |
| DB span (5 attrs) | 520 ns | 3.3 KB |
getContext | 2.9 ns | 15 B |
lookupSpan | 0.6 ns | 0 B |
For comparison, bare span create+end on the same workload (no attributes, AlwaysSample) is ~279 ns in the Go SDK and ~349 ns in the Rust SDK. Cross-language numbers are from different machines, so ratios are approximate.
Key design choices for low overhead
- Unboxed
Word64trace/span IDs (no heap-allocated byte arrays) - Thread-local xoshiro256++ RNG in C (no contention, no syscalls after seed)
- Direct
clock_gettimeFFI for timestamps (noalloca/errnooverhead) - Dedicated context slots for span and baggage (O(1), no
Vaultlookup) - No-op fast path skips
mask, context writes, and ID generation entirely INLINEon hot-path functions with case-of-case optimization for samplers
Run make bench.save to establish a baseline on your machine, then
make bench.check after changes to catch regressions above 20%.
Package Ecosystem
Instrumentation Libraries
Exporters
| Format | Package | Signals |
|---|---|---|
| OTLP | hs-opentelemetry-exporter-otlp | Traces, Metrics, Logs |
| Handle (stdout) | hs-opentelemetry-exporter-handle | Traces, Metrics, Logs |
| In-Memory | hs-opentelemetry-exporter-in-memory | Traces, Metrics, Logs |
| Prometheus | hs-opentelemetry-exporter-prometheus | Metrics |
Tip: For Honeycomb, Datadog, Grafana Cloud, and other OTLP-compatible backends, use
hs-opentelemetry-exporter-otlpwith the appropriate endpoint.
Propagators
| Format | Package | Module |
|---|---|---|
| W3C TraceContext | hs-opentelemetry-propagator-w3c | OpenTelemetry.Propagator.W3CTraceContext |
| W3C Baggage | hs-opentelemetry-propagator-w3c | OpenTelemetry.Propagator.W3CBaggage |
| B3 | hs-opentelemetry-propagator-b3 | OpenTelemetry.Propagator.B3 |
| Jaeger | hs-opentelemetry-propagator-jaeger | OpenTelemetry.Propagator.Jaeger |
| Datadog | hs-opentelemetry-propagator-datadog | OpenTelemetry.Propagator.Datadog |
| AWS X-Ray | hs-opentelemetry-propagator-xray | OpenTelemetry.Propagator.XRay |
GHC Compatibility
| GHC | Stack resolver | Notes |
|---|---|---|
| 9.4 | lts-21.25 | No hw-kafka-client, no gogol |
| 9.6 | lts-22.44 | No gogol |
| 9.8 | lts-23.28 | No gogol |
| 9.10 | lts-24.35 | Full support |
| 9.12 | nightly-2026-04-04 | No persistent-mysql; proto-lens via allow-newer |
Examples
Working application examples are in the examples/ directory:
- Yesod web application - WAI middleware, database spans, GHC metrics
- OTLP demo - Basic OTLP exporter setup with traces
- Hspec test integration - Running Hspec tests with OpenTelemetry instrumentation
- Kafka client example - Producer and consumer instrumentation with hw-kafka-client
Contributing
See CONTRIBUTING.md.
Maintainer: Ian Duncan