README.md

May 27, 2026 · View on GitHub

OpenTelemetry

OpenTelemetry for Haskell

Traces, metrics, and logs for Haskell applications and libraries

Hackage Hackage GHC License Sponsor


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 telemetryhs-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:

LoggerBridge
katiphs-opentelemetry-instrumentation-katip
co-loghs-opentelemetry-instrumentation-co-log
monad-loggerhs-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.

SignalAPI ModuleSDK ModuleStatus
TracesOpenTelemetry.Trace.CoreOpenTelemetry.TraceStable
MetricsOpenTelemetry.Metric.CoreOpenTelemetry.MeterProviderStable
LogsOpenTelemetry.Log.CoreOpenTelemetry.LogStable

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):

OperationTimeAllocated
inSpan no-op (no SDK)13.6 ns15 B
inSpan active218–445 ns1.2–2.5 KB
bare span (create+end)209 ns1.2 KB
HTTP span (3 attrs)410 ns2.5 KB
DB span (5 attrs)520 ns3.3 KB
getContext2.9 ns15 B
lookupSpan0.6 ns0 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 Word64 trace/span IDs (no heap-allocated byte arrays)
  • Thread-local xoshiro256++ RNG in C (no contention, no syscalls after seed)
  • Direct clock_gettime FFI for timestamps (no alloca/errno overhead)
  • Dedicated context slots for span and baggage (O(1), no Vault lookup)
  • No-op fast path skips mask, context writes, and ID generation entirely
  • INLINE on 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

LibraryPackageSignals
waihs-opentelemetry-instrumentation-waiTraces, Metrics
yesod-corehs-opentelemetry-instrumentation-yesodTraces
persistent / esqueletohs-opentelemetry-instrumentation-persistentTraces
persistent-mysqlhs-opentelemetry-instrumentation-persistent-mysqlTraces
postgresql-simplehs-opentelemetry-instrumentation-postgresql-simpleTraces
http-client / http-conduiths-opentelemetry-instrumentation-http-clientTraces, Metrics
conduiths-opentelemetry-instrumentation-conduitTraces
hw-kafka-clienths-opentelemetry-instrumentation-hw-kafka-clientTraces
amazonkahs-opentelemetry-instrumentation-amazonkaTraces
gogolhs-opentelemetry-instrumentation-gogolTraces
GHC runtimehs-opentelemetry-instrumentation-ghc-metricsMetrics
hspechs-opentelemetry-instrumentation-hspecTraces
tastyhs-opentelemetry-instrumentation-tastyTraces
katiphs-opentelemetry-instrumentation-katipLogs
co-loghs-opentelemetry-instrumentation-co-logLogs
monad-loggerhs-opentelemetry-instrumentation-monad-loggerLogs
cloudflarehs-opentelemetry-instrumentation-cloudflareTraces

Exporters

FormatPackageSignals
OTLPhs-opentelemetry-exporter-otlpTraces, Metrics, Logs
Handle (stdout)hs-opentelemetry-exporter-handleTraces, Metrics, Logs
In-Memoryhs-opentelemetry-exporter-in-memoryTraces, Metrics, Logs
Prometheushs-opentelemetry-exporter-prometheusMetrics

Tip: For Honeycomb, Datadog, Grafana Cloud, and other OTLP-compatible backends, use hs-opentelemetry-exporter-otlp with the appropriate endpoint.

Propagators

FormatPackageModule
W3C TraceContexths-opentelemetry-propagator-w3cOpenTelemetry.Propagator.W3CTraceContext
W3C Baggagehs-opentelemetry-propagator-w3cOpenTelemetry.Propagator.W3CBaggage
B3hs-opentelemetry-propagator-b3OpenTelemetry.Propagator.B3
Jaegerhs-opentelemetry-propagator-jaegerOpenTelemetry.Propagator.Jaeger
Datadoghs-opentelemetry-propagator-datadogOpenTelemetry.Propagator.Datadog
AWS X-Rayhs-opentelemetry-propagator-xrayOpenTelemetry.Propagator.XRay

GHC Compatibility

GHCStack resolverNotes
9.4lts-21.25No hw-kafka-client, no gogol
9.6lts-22.44No gogol
9.8lts-23.28No gogol
9.10lts-24.35Full support
9.12nightly-2026-04-04No persistent-mysql; proto-lens via allow-newer

Examples

Working application examples are in the examples/ directory:

Contributing

See CONTRIBUTING.md.

Maintainer: Ian Duncan