BSD-3-Clause licensed by Ian Duncan, Jade Lovelace
Maintained by [email protected]
This version can be pinned in stack with:hs-opentelemetry-sdk-1.0.0.0@sha256:461ed9f6e0c728e852162b08090ca251b6340fd87ec84994f596b32203dc9d83,6169

Module documentation for 1.0.0.0

OpenTelemetry SDK for Haskell

hs-opentelemetry-sdk

This package provides everything a functioning implementation of the OpenTelemetry the API requires, useful for exporting a variety of tracing, logging, and metric data.

Why use OpenTelemetry tracing?

If you’re running a user-facing software service, it probably qualifies as a distributed service. You might have a proxy, an application and a database, or a more complicated microservice architecture. Regardless of the level of complexity, a distributed system means that multiple distinct services must work together in concert.

Tracing helps tie together instrumentation from separate services, or from different methods within one service. This makes it easier to identify the source of errors, find performance problems, or understand how data flows through a large system.

What is a Trace?

A trace tells the story of a complete unit of work in your system. A unit of work is generally application specific, but often comes in some of the following forms:

  • An HTTP request initiated by a user or third party.
  • Execution of a cron job.
  • An async task being pulled from a queue and processed.

For example, when a user loads a web page, their request might go to an edge proxy and/or load balancer. That proxy talks to a Haskell web service, which calls out to a Redis cache and PostgreSQL database. There could be multiple calls out to third-party services via HTTP APIs. Finally, the backend returns a result to the client.

Spans

Each portion of the web request’s lifecycle can be told by a span. A span is a single piece of instrumentation from a single location in your code or infrastructure. A span represents a single “unit of work” done by a service. Each span contains several key pieces of data:

  • A service name identifying the service the span is from
  • A name identifying the role of the span (like function or method name)
  • A timestamp that corresponds to the start of the span
  • A duration that describes how long that unit of work took to complete
  • An ID that uniquely identifies the span
  • A trace ID identifying which trace the span belongs to
  • A parent ID representing the parent span that called this span. (There is no parent ID for the root span of a given trace, which denotes that it’s the start of the trace.)
  • Any additional metadata that might be helpful.
  • Zero or more links to related spans. Links can be useful for connecting causal relationships between things like web requests that enqueue asynchronous tasks to be processed.
  • Events, which denote a point in time occurrence. These can be useful for recording data about a span such as when an exception was thrown, or to emit structured logs into the span tree.

A trace is made up of multiple spans. Tracing vendors such as Zipkin, Jaeger, Honeycomb, Datadog, Lightstep, etc. use the metadata from each span to reconstruct the relationships between them and generate a trace diagram.

Context

In order for OpenTelemetry to work, it must store and propagate important telemetry data. For example, when a request is received and a span is started it must be available to component which want to create child spans. To solve this problem, OpenTelemetry stores the span in a data structure called Context.

A Context is an specialized map structure structure that can store values of arbitrary types. As your code executes, the current active span will be stored in the Context. Creating new spans requires a Context, which is used to determine the parent span for the newly created span (if a parent exists). At the outermost edges of your “complete unit of work” that you choose to instrument, you can start with an empty context in order to create a root span.

Exporting

Once you have an application that is instrumented to track interesting data about the lifecycle and execution of your units of work, you need to send them somewhere! OpenTelemetry has the concept of an Exporter, which is an interface that receives a set of spans that have completed, and outputs them to a target of your choosing. Different vendors provide a number of useful tools for using exported span data to understand and monitor how your system is behaving in production.

See the main project README for a list of supported exporters.

Sampling

In large production systems, it is often not desirable to perform tracing for every request that comes through the system. Sampling is a way to reduce the amount of data you send to OpenTelemetry without a significant reduction in the quality of your data. It’s like getting samples of food: you can taste all the important bits without getting full.

Sampling is a process that restricts the amount of traces that are generated by a system. The exact sampler you should use depends on your specific needs, but in general you should make a decision at the start of a trace, and allow the sampling decision to propagate to other services.

Typically, the way traces are sampled works like this: when the root span is being processed, a random sampling decision is made. If that span is decided to be sampled, it is exported and also propagates that decision out to the descendent spans, who follow suit, usually via injected HTTP headers signifying that the trace is selected to be sampled. That way, all the spans for a particular trace are preserved.

Processing

Tracer

The OpenTelemetry Tracing API uses a data type called a Tracer to create traces. These Tracers are designed to be associated with one instrumentation library. That way, telemetry they produce can be understood to come from the library or portion of your code base that it instruments.

A Tracer is constructed by calling the makeTracer function, which requires a TracerProvider and TracerOptions, which we’ll discuss next.

TracerProvider

A TracerProvider is key to using OpenTelemetry tracing. It is the data structure responsible for designating how spans are processed and exported to external systems.

Install Dependencies

Add hs-opentelemetry-sdk to your package.yaml or Cabal file.

Metrics (SDK)

  1. Build a resource (e.g. emptyMaterializedResources or service detectors).
  2. createMeterProvider resource defaultSdkMeterProviderOptions (adjust views, aggregationTemporality, exemplarOptions, cardinalityLimit as needed).
  3. getMeter provider yourInstrumentationLibrary and create instruments (meterCreateCounterInt64, meterCreateHistogram, …).
  4. Export: build a MetricExporter (e.g. otlpMetricExporter from loadExporterEnvironmentVariables), then either forkPeriodicMetricReader env exporter =<< periodicMetricReaderOptionsFromEnv, or on each scrape call exportMetricsOnce env exporter (pull-style HTTP handler).
  5. Use otlpMetricExporter from OpenTelemetry.Exporter.OTLP.Metric with loadExporterEnvironmentVariables / OTLPExporterConfig for OTLP/HTTP, or renderPrometheusText from OpenTelemetry.Exporter.Prometheus for Prometheus text.

Shutdown: stop the periodic reader (if any), then meterProviderShutdown on the provider.

Trace Your Code

Initialization

Get started by importing the OpenTelemetry.Trace module. It exports most of what you need to instrument your application.

import OpenTelemetry.Trace

Install a global TracerProvider for your code. Instrumentation libraries and directly instrumented systems will generally use getGlobalTracerProvider to create their Tracers, since there is often a constraint that function signatures should not make breaking changes. The getGlobalTracerProvider allows OpenTelemetry to smuggle in the ability to emit tracing details without breaking existing APIs for users that aren’t even using OpenTelemetry.

main :: IO ()
main = withTracer $ \tracer -> do
  -- your existing code here...
  pure ()
  where
    withTracer :: ((TracerOptions -> Tracer) -> IO c) -> IO c
    withTracer f = bracket 
      -- Install the SDK, pulling configuration from the environment
      initializeGlobalTracerProvider
      -- Ensure that any spans that haven't been exported yet are flushed
      shutdownTracerProvider
      -- Get a tracer so you can create spans
      (\tracerProvider -> f $ makeTracer tracerProvider "your-app-name-or-subsystem")

The primary configuration mechanism for initializeGlobalTracerProvider is via the environment variables listed in the official OpenTelemetry specification.

These environment variables provide extensive configuration options for the samplers and exporters to use. Not all of the environment variables listed are fully supported yet (contributions welcome!), so make sure to validate in a development context that your configuration settings are behaving as expected.

Start Tracing

In order to create some spans, you’ll need a Tracer. It’s usually a good idea to make your tracer available in whatever monadic contexts you frequently use:

import OpenTelemetry.Trace hiding (inSpan)
import OpenTelemetry.Trace.Monad

instance MonadTracer YourMonadHere where
  getTracer = ...

Now you can get a Tracer when you need it! Now, find a function towards the outer edges of your unit of work that you want to instrument:

handleWebRequest :: Request -> IO Response
handleWebRequest req = makeResponse
  where
    makeResponse = ...

… and use one of the inSpan variants to wrap it like this:

handleWebRequest :: Request -> IO Response
handleWebRequest req = inSpan' (requestPath req) spanArgs $ \webReqSpan -> do
  resp <- makeResponse
  addEvent $ NewEvent
    { name = "made a response"
    , newEventAttributes = []
    , newEventTimestamp = Nothing -- will be auto-generated if not supplied
    }
  annotateResponseInfo webReqSpan resp
  pure resp
  where
    makeResponse = ...
    spanArgs = defaultSpanArguments
      { attributes =
          [ ("user.id", toAttribute (1 :: Int))
          , ("http.request.headers.idempotency-key", toAttribute $ fromMaybe "" $ lookupIdempotencyKey req)
          ]
      }
    annotateResponseInfo webReqSpan resp = addAttributes webReqSpan
      [ ("http.status_code", toAttribute $ responseStatus resp)
      ]

inSpan looks up the current parent span from the thread-local[^thread-local-state] Context if one exists, and uses it to create a span that is appropriately tied to the rest of the trace. It will record and rethrow any unhandled synchronous exceptions, and when the code executing in the passed in function completes, the span is completed for final processing and export to your configured exporter. Once the inSpan execution completes, it will restore the thread-local Context to the state it had prior to execution.

defaultSpanArguments allows for adding starting attributes to a span, as well as providing links to related spans, and specifying the SpanKind.

addAttributes can be used to progressively enrich spans with data as execution proceeds, so you can fully capture the outcome of code as it executes.

This just scratches the surface of the capabilities that OpenTelemetry tracing provides for understanding your systems in production. See the OpenTelemetry.Trace module for more of the functionality available to you.

Launch your app!

Out of the box, your instrumented app will attempt to send trace information to localhost. We recommend running an instance of the OpenTelemetry Collector locally where possible, but can also set environment variables to configure your application to use different exporters, endpoints, and more:

OTEL_EXPORTER_OTLP_ENDPOINT="https://api.vendor.xyz" OTEL_EXPORTER_OTLP_HEADERS="x-vendor-api-key=$YOUR_API_KEY,x-vendor-dataset=$YOUR_VENDOR_DATASET_NAME" stack exec yesod-minimal

See the environment variable mentioned earlier in the README for the full list.

Examples of instrumented systems are available here: Instrumentated application examples.

Visit the GitHub project for a list of provided instrumentation libraries. We support several packages like wai, persistent, and yesod already, and want to provide official instrumentation for as much of the Haskell ecosystem as possible. We’d love to also have you contribute instrumentation packages to the project if you wrap any public packages yourself.

Useful Links

[^thread-local-state]: Thread-local here meaning that the state is scoped to the current Haskell green thread. If you do anything concurrently via e.g. forkIO, you’ll need to use OpenTelemetry.Context.ThreadLocal to attach the Context to your new thread.

Changes

Changelog for hs-opentelemetry-sdk

Unreleased

1.0.0.0 - 2026-05-29

Spec conformance (1.55.0 audit)

Performance

  • Batch processor: switched to unagi-chan bounded queue with power-of-two sizing. tryWriteChan is non-blocking; drain uses estimatedLength for batch sizing. Export groups spans by tracer at drain time. Concurrent chunk export via mapConcurrently_.
  • Simple processor: synchronous export in onEnd/onEmit (no thread overhead). Matches Go/Java/Python SDK design for low-throughput use cases.
  • Metrics: AtomicBucketArray for histogram buckets (single MutableByteArray# with fetchAddIntArray#, zero vector copying on record). Separate SumIntCell/SumDblCell to avoid boxing. Binary search for bucket index. OptionalDouble sentinel for min/max instead of Maybe Double.
  • Default ID generator: thread-local xoshiro256++ in C, replacing the Haskell random package (System.Random.Stateful) that was used on origin/main. No contention, no syscalls, no Haskell allocation after initial seed.

Bug fixes

  • Batch processor shutdown deadlock fixed. Second shutdownTracerProvider / shutdownLoggerProvider call would hang forever because putTMVar blocks when the worker has already consumed the signal. Fixed with tryPutTMVar + IORef shutdown guard. OnEnd/OnEmit are now also guarded to prevent buffer growth after shutdown.
  • Counter rejects negative values. Monotonic counters now drop negative deltas per spec. Previously, negative values were summed into the same cell, producing incorrect monotonic sums.
  • MeterProvider.shutdown is now idempotent. Second call returns ShutdownSuccess immediately without re-running collection, export, or exporter shutdown.
  • OTEL_SDK_DISABLED=true no longer disables propagators. detectPropagators is now always called, even when the SDK is disabled, so setGlobalTextMapPropagator runs and instrumentation libraries can still propagate context.
  • service.name precedence fixed. OTEL_SERVICE_NAME now takes precedence over service.name defined in OTEL_RESOURCE_ATTRIBUTES, matching the spec.
  • OTLP exporters return Failure after shutdown. spanExporterExport and logRecordExporterExport now check a shutdown flag and return Failure Nothing after shutdown() is called.
  • Simple processors have 30s export timeout. export() in simple span and log record processors is now wrapped in a timeout to prevent indefinite blocking.
  • BSP default maxQueueSize fixed from 1024 to 2048. Now matches the spec default and the documentation table.

Changes

  • detectPropagators and createFromConfig now set the global propagator. The SDK initialization path (initializeGlobalTracerProvider and createFromConfig) now calls setGlobalTextMapPropagator, making propagators available via the global API. Instrumentation libraries (WAI, http-client, hw-kafka-client) now use getGlobalTextMapPropagator instead of extracting propagators from the TracerProvider.
  • OTEL_PROPAGATORS values are now deduplicated and whitespace-stripped. Per spec: “Values MUST be deduplicated in order to register a Propagator only once.”
  • Breaking: SimpleSpanProcessor and SimpleLogRecordProcessor now export synchronously. onEnd / onEmit calls the exporter directly on the calling thread instead of enqueueing to an unbounded async channel. This matches the OTel specification (“passes finished spans directly to the configured SpanExporter”) and the behavior of every other OTel SDK: Go, Java, .NET, C++, Rust, and Python all export synchronously in their simple processors. The previous unbounded unagi-chan queue could grow without bound under backpressure. Use BatchSpanProcessor / BatchLogRecordProcessor for non-blocking, production-grade processing.
  • Metric storage: per-instrument IORef replaces global IORef. Each instrument now owns its own IORef (HashMap Attributes Cell), eliminating cross-instrument contention on the recording hot path. Same-name instrument re-registration shares the underlying IORef (spec MUST). SdkMeterStorageState, DimKey, and seriesCountByDims are removed.
  • Fix: TOCTOU race in instrument registration. getOrCreateInstrumentStorage now performs the lookup and insertion inside a single atomicModifyIORef', preventing duplicate IORefs for the same instrument under concurrent registration.
  • Fix: delta temporality lost-update bug. collectResourceMetrics now atomically snapshots and resets each instrument’s cell map in one atomicModifyIORef', preventing recordings between snapshot and reset from being silently dropped.
  • Fix: metric export grouping. buildResourceExport now groups by InstrumentationLibrary (scope) with each instrument producing an independent metric export, rather than merging instruments that share (scope, name, kind, unit, description) but differ in histogram aggregation or export attribute keys.
  • Fix: OTEL_CONFIG_FILE resource.schema_url. buildResource now applies resourceSchemaUrl from the config to the materialized resource.
  • Fix: view matching ignoring unit and meter scope. findMatchingView, shouldDropInstrument, viewOverrideName, viewOverrideDescription, and exportKeysFor now receive real instrument unit and meter scope. Previously views with unit or meter-name/version/schema_url selectors never matched.
  • Fix: batch processor worker crash on export exception. Both batch span and batch log processors now catch SomeException around publish, preventing the worker Async from dying permanently on a transient exporter failure.
  • Fix: unsorted explicit histogram bucket boundaries. Advisory and view-supplied bucket boundaries are now sorted before use, preventing incorrect bucket placement.
  • Fix: batch processor off-by-one in queue capacity. Both BatchSpanProcessor and BatchLogRecordProcessor rejected items when count + 1 >= maxQueueSize, meaning a queue configured for 1024 items only held 1023. Changed to count >= maxQueueSize so the queue accepts exactly maxQueueSize items.
  • Fix: simple processor shutdown flags used non-atomic writeIORef. Both SimpleSpanProcessor and SimpleLogRecordProcessor now use atomicWriteIORef for the shutdown flag, ensuring happens-before visibility to concurrent readers.
  • Fix: MeterProvider shutdown flag used non-atomic writeIORef. Now uses atomicWriteIORef for the shutdown boolean.
  • Fix: detectSpanLimits swapped OTEL_SPAN_LINK_COUNT_LIMIT and OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT. Positional applicative construction mapped link count limit to eventAttributeCountLimit and vice versa. Corrected field ordering.
  • Batch processor ForceFlush now blocks until the worker completes an export cycle. Previously ForceFlush signaled the worker and returned immediately, offering no guarantee that buffered spans/logs were exported before the caller continued. The new implementation uses a generation counter with a timeout derived from exportTimeoutMillis.
  • Batch processor maxExportBatchSize is now enforced as a hard per-export limit. The buffer is drained fully, then chunked into batches of at most maxExportBatchSize items before each chunk is exported separately. Matches the OTel spec requirement.
  • Per-export timeout on batch processor. Individual export calls are wrapped in System.Timeout.timeout exportTimeoutMillis. A timed-out export returns Failure without killing the worker.
  • ReadableLogRecord is now a true point-in-time snapshot. mkReadableLogRecord reads the IORef and stores the ImmutableLogRecord directly, so exporters see a consistent view regardless of concurrent mutations. mkReadableLogRecord is now IO (breaking change to the internal API).
  • Observable callback handles now support real unregistration. ObservableCallbackHandle.unregisterObservableCallback removes the callback from the meter’s collection registry. Previously it was a no-op. Internally, callbacks are stored in an IntMap keyed by unique ID rather than a Seq.
  • Implement declarative SDK configuration via OTEL_CONFIG_FILE (OpenTelemetry.Configuration)
    • YAML parsing with environment variable substitution (${VAR}, ${env:VAR:-default})
    • In-memory configuration data model (OpenTelemetry.Configuration.Types)
    • Full Create operation: TracerProvider, MeterProvider, LoggerProvider, Propagators from config
    • Supports OTLP HTTP, console, and none exporters; batch and simple processors
    • Sampler configuration: always_on, always_off, trace_id_ratio_based, parent_based
    • Resource, attribute limits, span limits, propagator configuration
  • Shutdown/ForceFlush propagation audit:
    • Batch span processor now calls spanExporterShutdown during processor shutdown (was missing)
    • Batch log processor now calls logRecordExporterShutdown during processor shutdown
    • MeterProvider shutdown now does a final collect + export + metricExporterShutdown when an exporter is configured
    • MeterProvider forceFlush now does collect + export + metricExporterForceFlush when an exporter is configured
    • SdkMeterProviderOptions gains metricExporter :: Maybe MetricExporter field
    • Periodic metric reader stop now calls metricExporterShutdown after final export
    • forceFlushTracerProvider exported from OpenTelemetry.Trace (SDK)
  • Implement SimpleLogRecordProcessor: processes log records inline, passes them to configured LogRecordExporter
  • Implement BatchLogRecordProcessor: batches log records with configurable queue size, export interval, and timeout
  • Batch/simple span processors now call spanExporterForceFlush during processor ForceFlush
  • IsValid test coverage expanded for TraceId-only and SpanId-only zero cases
  • Track startTimeUnixNano across all data points (was hardcoded to 0)
  • ForceFlush on MeterProvider now triggers a metric collect
  • View supports name and description overrides
  • ViewSelector expanded: name (wildcard), kind, unit, meter_name, meter_version, meter_schema_url criteria (spec MUST)
  • findAllMatchingViews for multi-view-stream support
  • Instrument name matching is now case-insensitive (spec MUST)
  • Cardinality overflow: excess series aggregated under otel.metric.overflow=true (spec SHOULD)
  • Default explicit histogram bounds updated to spec: [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000]
  • NaN/Inf measurements silently dropped in recordHist/recordExpHist (spec MUST)
  • Advisory Attributes parameter used as fallback when View has no attribute_keys (spec SHOULD)
  • ExemplarFilter: TraceBased (default), AlwaysOn, AlwaysOff: replaces boolean exemplarCaptureTraceContext
  • OTEL_METRICS_EXEMPLAR_FILTER env var fully wired into SDK
  • New OpenTelemetry.Metrics.ExporterSelection module: wire OTEL_METRICS_EXPORTER to concrete MetricExporter
  • Comprehensive test coverage for all instrument types, views, delta temporality, observables
  • SdkMeterProviderOptions: aggregationTemporality, views, exemplarOptions
  • OpenTelemetry.Metrics.View: instrument selection and aggregation overrides (including drop).
  • Exponential histogram aggregation, exemplars, delta temporality with post-collect reset (gauges unchanged).
  • Observable callbacks collected in FIFO order; MetricReader.periodicMetricReaderOptionsFromEnv for OTEL_METRIC_EXPORT_INTERVAL.

0.1.0.1

  • Update dependency bounds for hs-opentelemetry-api 0.3.0.0

0.1.0.0

  • Support new versions of dependencies.
  • Windows: Replace POSIX-only functionality with a stub, so the package could be built at all (#114).
  • Support OTEL_SDK_DISABLED (#148).
  • Add Datadog as a known propagator (#117).
  • Documentation improvements

0.0.3.6

  • Raise minimum version bounds for random to 1.2.0. This fixes duplicate ID generation issues in highly concurrent systems.

0.0.3.3

  • Fix batch processor flush behavior on shutdown to not drop spans

0.0.3.2

  • Fix haddock issue

0.0.3.1

  • getTracerProviderInitializationOptions' introduced to enable custom resource detection

0.0.2.1

  • Doc enhancements
  • makeTracer introduced to replace getTracer
  • Tighten exports. Not likely to cause any breaking changes for existing users.

0.0.2.0

  • Update hs-opentelemetry-api bounds
  • Export new NewLink interface for creating links

0.0.1.0

  • Initial release