Hs opentelemetry
OpenTelemetry support for the Haskell programming language
Traces, metrics, and logs for Haskell applications and libraries The project is written primarily in Haskell, distributed under the Other license, first published in 2021. Key topics include: haskell, honeycomb, logging, metrics, observability.
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:
haskellhandleRequest 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:
haskellhandleRequest 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.
haskellimport 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:
haskellinSpan' 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.
haskellimport 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:
haskellimport 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.
haskellimport 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:
haskellimport 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.
- 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,
usehs-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
Contributors
Showing top 12 contributors by commit count.
