Skip to content

OTLP & Prometheus exporters

Effect ships its own OpenTelemetry exporters in effect/unstable/observability. These modules turn Effect’s built-in logs, traces, and metrics into OTLP/HTTP payloads (or a Prometheus scrape body) and push them to a collector — with no extra OpenTelemetry SDK runtime required.

The fastest setup is Otlp.layerJson, which wires logs, metrics, and traces to a single OTLP/HTTP collector from one baseUrl. It appends /v1/logs, /v1/metrics, and /v1/traces for you, so pass the collector root (e.g. http://localhost:4318), not a signal path.

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
// One layer for logs + metrics + traces.
// `layerJson` bundles the JSON serializer, so you only need an HttpClient.
export const ObservabilityLayer = Otlp.layerJson({
baseUrl: "http://localhost:4318",
resource: {
serviceName: "checkout-api",
serviceVersion: "1.0.0",
attributes: {
"deployment.environment": "staging"
}
}
}).pipe(
// Provide the HttpClient used by every exporter, at the edge of your app.
Layer.provide(FetchHttpClient.layer)
)

Provide ObservabilityLayer once, at the very end of your layer graph, so that every span, log, and metric produced by the application is exported:

import { Context, Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { NodeRuntime } from "@effect/platform-node"
const ObservabilityLayer = Otlp.layerJson({
baseUrl: "http://localhost:4318",
resource: { serviceName: "checkout-api", serviceVersion: "1.0.0" }
}).pipe(Layer.provide(FetchHttpClient.layer))
class Checkout extends Context.Service<Checkout, {
processCheckout(orderId: string): Effect.Effect<void>
}>()("acme/Checkout") {
static readonly layer = Layer.effect(
Checkout,
Effect.gen(function*() {
return Checkout.of({
processCheckout: Effect.fn("Checkout.processCheckout")(function*(orderId: string) {
yield* Effect.logInfo("starting checkout", { orderId })
yield* Effect.sleep("50 millis").pipe(
Effect.withSpan("checkout.charge-card"),
Effect.annotateSpans({ "checkout.order_id": orderId })
)
yield* Effect.logInfo("checkout completed", { orderId })
})
})
})
)
}
const Main = Layer.effectDiscard(
Effect.gen(function*() {
const checkout = yield* Checkout
yield* checkout.processCheckout("ord_123")
})
).pipe(
Layer.provide(Checkout.layer),
// Provide observability last so all app telemetry is exported.
Layer.provide(ObservabilityLayer)
)
Layer.launch(Main).pipe(NodeRuntime.runMain)

layerJson is convenient and easy to inspect, but many production collectors expect binary protobuf. Use layerProtobuf for those — it sets the application/x-protobuf content type:

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
const ObservabilityLayer = Otlp.layerProtobuf({
baseUrl: "https://otlp.example.com",
// Authenticate / route via headers
headers: { authorization: "Bearer <token>" },
metricsTemporality: "delta" // e.g. for Datadog / Dynatrace
}).pipe(Layer.provide(FetchHttpClient.layer))

If you need a custom encoder, use Otlp.layer instead and provide your own OtlpSerialization implementation (see below).

When the three signals go to different endpoints (or you only want one of them), compose the signal-specific layers directly. Each requires both OtlpSerialization and HttpClient, which you provide once at the edge:

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
const Tracing = OtlpTracer.layer({
url: "http://localhost:4318/v1/traces",
resource: { serviceName: "checkout-api" }
})
const Logging = OtlpLogger.layer({
url: "http://localhost:4318/v1/logs",
resource: { serviceName: "checkout-api" }
})
export const ObservabilityLayer = Layer.merge(Tracing, Logging).pipe(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer)
)

If you have an existing OpenTelemetry pipeline (custom span processors, the OTel metric SDK, vendor instrumentation), the @effect/opentelemetry package’s NodeSdk layer bridges Effect into that ecosystem instead. The effect/unstable/observability exporters in this page are the dependency-light alternative: they speak OTLP/HTTP and Prometheus directly without pulling in the OTel SDK.


The combined exporter for logs, metrics, and traces. Sends to /v1/logs, /v1/metrics, and /v1/traces below baseUrl. Leaves OtlpSerialization in the requirements so you can plug in a custom serializer; requires HttpClient.

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp, OtlpSerialization } from "effect/unstable/observability"
const layer = Otlp.layer({
baseUrl: "http://localhost:4318",
resource: { serviceName: "api", serviceVersion: "2.1.0" },
headers: { authorization: "Bearer t0ken" },
maxBatchSize: 1000, // flush logs/traces when buffered count hits this
loggerExportInterval: "1 second",
loggerExcludeLogSpans: false, // export logSpan.<label> duration attributes
loggerMergeWithExisting: true, // keep the default console logger too
metricsExportInterval: "10 seconds",
metricsTemporality: "cumulative",
tracerExportInterval: "5 seconds",
shutdownTimeout: "3 seconds" // max time to flush buffers on shutdown
}).pipe(
Layer.provide(OtlpSerialization.layerProtobuf),
Layer.provide(FetchHttpClient.layer)
)
// => Layer<never, never, never>

The same combined exporter, with the serializer pre-bundled. They require only HttpClient. layerJson posts OTLP/HTTP JSON; layerProtobuf posts binary protobuf with Content-Type: application/x-protobuf.

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
const json = Otlp.layerJson({ baseUrl: "http://localhost:4318" })
const proto = Otlp.layerProtobuf({ baseUrl: "http://localhost:4318" })
const layer = Layer.provide(json, FetchHttpClient.layer)
// => Layer<never, never, never>

Provides a Tracer.Tracer that batches ended, sampled spans and posts them to a traces endpoint. Span attributes, events, links, status, parent ids, and failure causes (as exception events) are converted to OTLP trace data.

import { OtlpTracer } from "effect/unstable/observability"
const tracerLayer = OtlpTracer.layer({
url: "http://localhost:4318/v1/traces",
resource: { serviceName: "api" },
exportInterval: "5 seconds", // default
maxBatchSize: 1000, // default
shutdownTimeout: "3 seconds"
})
// => Layer<never, never, OtlpSerialization | HttpClient>

OtlpTracer.make returns the Tracer.Tracer as an Effect (requiring OtlpSerialization | HttpClient | Scope) for manual composition. The optional context hook lets a backend evaluate around the active span. The serialized payload follows the exported TraceData shape:

import type { OtlpTracer } from "effect/unstable/observability"
// TraceData -> { resourceSpans: ResourceSpan[] }
// ResourceSpan -> { resource, scopeSpans: ScopeSpan[] }
// ScopeSpan -> { scope, spans }
declare const data: OtlpTracer.TraceData

Turns every Effect log call into an OTLP log record, carrying severity, timestamp, fiber id, log annotations, the failure cause (log.error), and the current trace/span ids. Active log spans become logSpan.<label> duration attributes unless excludeLogSpans is set.

import { OtlpLogger } from "effect/unstable/observability"
const loggerLayer = OtlpLogger.layer({
url: "http://localhost:4318/v1/logs",
resource: { serviceName: "api" },
exportInterval: "1 second", // default
maxBatchSize: 1000, // default
shutdownTimeout: "3 seconds",
excludeLogSpans: false,
mergeWithExisting: true // keep existing loggers (default true)
})
// => Layer<never, never, OtlpSerialization | HttpClient>

OtlpLogger.make returns the Logger.Logger as an Effect (requiring OtlpSerialization | HttpClient | Scope) for use with a custom Logger.layer. The exported payload type is LogsData ({ resourceLogs: ... }).

Periodically snapshots the metrics registered in the current Effect context (counters, gauges, histograms, frequencies, summaries) and posts them as OTLP resource metrics. The snapshot model means there is no per-record batching — it uses maxBatchSize: "disabled" internally.

import { OtlpMetrics } from "effect/unstable/observability"
const metricsLayer = OtlpMetrics.layer({
url: "http://localhost:4318/v1/metrics",
resource: { serviceName: "api" },
exportInterval: "10 seconds", // default
shutdownTimeout: "3 seconds",
temporality: "cumulative" // default; see below
})
// => Layer<never, never, OtlpSerialization | HttpClient>

OtlpMetrics.make returns an Effect<void, never, HttpClient | OtlpSerialization | Scope> that starts the scoped exporter. The exported payload type is MetricsData ({ resourceMetrics: ... }).

How metric values relate to their aggregation interval. "cumulative" (default) reports totals since a fixed start time (good for Prometheus-style backends); "delta" reports the change since the last export (preferred by Datadog, Dynatrace, etc.). Gauges always report their current value regardless of temporality.

import type { OtlpMetrics } from "effect/unstable/observability"
const t: OtlpMetrics.AggregationTemporality = "delta"
// => "cumulative" | "delta"

The service the exporters call to encode each payload into an HTTP body. There is one encoder per signal: traces, metrics, and logs.

  • OtlpSerialization.layerJson — encodes with HttpBody.jsonUnsafe (human-readable; good for debugging and OTLP/HTTP JSON endpoints).
  • OtlpSerialization.layerProtobuf — encodes binary OTLP protobuf with application/x-protobuf (expected by most production collectors).
import { Layer } from "effect"
import { HttpBody } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
const json = OtlpSerialization.layerJson
const proto = OtlpSerialization.layerProtobuf
// => Layer<OtlpSerialization>
// Provide a custom encoder only for non-standard body formats. The service has
// one encoder per signal; each returns an HttpBody.
const custom = Layer.succeed(OtlpSerialization.OtlpSerialization, {
traces: (data) => HttpBody.jsonUnsafe(data),
metrics: (data) => HttpBody.jsonUnsafe(data),
logs: (data) => HttpBody.jsonUnsafe(data)
})

Builds the OTLP Resource (service identity + shared attributes) attached to every exported signal.

OtlpResource.make constructs a resource from known metadata:

import { OtlpResource } from "effect/unstable/observability"
const resource = OtlpResource.make({
serviceName: "checkout-api",
serviceVersion: "1.0.0",
attributes: { "deployment.environment": "prod", region: "us-east-1" }
})
// => { attributes: [...service.name, service.version, ...], droppedAttributesCount: 0 }

OtlpResource.fromConfig merges explicit options with the standard OTEL environment variables (OTEL_RESOURCE_ATTRIBUTES, OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION), with explicit options winning. This is what every exporter calls under the hood.

import { Effect } from "effect"
import { OtlpResource } from "effect/unstable/observability"
const program = Effect.gen(function*() {
// Reads OTEL_SERVICE_NAME if serviceName is not passed
const resource = yield* OtlpResource.fromConfig({ serviceName: "api" })
return resource
})
// => Effect<Resource> (dies if no service.name can be resolved)

OtlpResource.serviceNameUnsafe reads the service.name back out of a resource (throws if absent):

import { OtlpResource } from "effect/unstable/observability"
const resource = OtlpResource.make({ serviceName: "api" })
OtlpResource.serviceNameUnsafe(resource)
// => "api"

OtlpResource.entriesToAttributes converts key/value entries into OTLP KeyValue[], and OtlpResource.unknownToAttributeValue converts a single value into an OTLP AnyValue:

import { OtlpResource } from "effect/unstable/observability"
OtlpResource.entriesToAttributes([["env", "prod"], ["port", 8080]])
// => [{ key: "env", value: { stringValue: "prod" } },
// { key: "port", value: { intValue: 8080 } }]
OtlpResource.unknownToAttributeValue(3.14)
// => { doubleValue: 3.14 }
OtlpResource.unknownToAttributeValue(["a", "b"])
// => { arrayValue: { values: [{ stringValue: "a" }, { stringValue: "b" }] } }

The related payload model types are Resource, KeyValue, AnyValue, ArrayValue, KeyValueList, LongBits, and Fixed64 (the runtime form of a 64-bit value).

The low-level scoped batch transport the three signal exporters build on. You rarely call this directly — it owns the export loop: it buffers pushed items, POSTs encoded batches on exportInterval, retries transient failures (honoring retry-after on HTTP 429), flushes on scope finalization up to shutdownTimeout, and after an unrecovered failure drops the batch and disables exporting for 60 seconds.

import { Effect } from "effect"
import { OtlpExporter } from "effect/unstable/observability"
import { HttpBody } from "effect/unstable/http"
// Build a custom signal exporter on top of the shared transport.
const program = Effect.gen(function*() {
const exporter = yield* OtlpExporter.make({
label: "MyExporter",
url: "http://localhost:4318/v1/logs",
headers: undefined,
exportInterval: "1 second",
maxBatchSize: 1000, // or "disabled" for pull-style bodies
body: (items) => HttpBody.jsonUnsafe(items),
shutdownTimeout: "3 seconds"
})
exporter.push({ some: "record" })
})
// => Effect<void, never, HttpClient | Scope>

For pull-based scraping, PrometheusMetrics renders the metrics registered in the current Effect context into the Prometheus text exposition format. It does not push, schedule, or start a server — formatting happens at scrape time.

Adds a GET /metrics route to an HttpRouter that returns the scrape body with the text/plain; version=0.0.4; charset=utf-8 content type. Wire it into the same context that records your metrics.

import { Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { PrometheusMetrics } from "effect/unstable/observability"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { createServer } from "node:http"
// Register GET /metrics (customize via path / prefix / metricNameMapper)
const PrometheusRoute = PrometheusMetrics.layerHttp({
path: "/metrics", // default
prefix: "myapp" // prepend "myapp_" to every metric name
})
const ServerLayer = HttpRouter.serve(PrometheusRoute).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 9464 }))
)
Layer.launch(ServerLayer).pipe(NodeRuntime.runMain)
// scrape: curl http://localhost:9464/metrics

Returns the current registry rendered as a string (Effect<string>). Use this to build a custom scrape endpoint or to inspect output in a test.

import { Effect, Metric } from "effect"
import { PrometheusMetrics } from "effect/unstable/observability"
const program = Effect.gen(function*() {
const counter = Metric.counter("http_requests_total", {
description: "Total HTTP requests"
})
yield* Metric.update(counter, 42)
const text = yield* PrometheusMetrics.format({ prefix: "myapp" })
return text
})
// => "# HELP myapp_http_requests_total Total HTTP requests\n" +
// "# TYPE myapp_http_requests_total counter\n" +
// "myapp_http_requests_total 42\n"

The synchronous, lower-level variant that takes an explicit Context and returns the string directly. Prefer format unless you already hold the context.

import { Effect } from "effect"
import { PrometheusMetrics } from "effect/unstable/observability"
const program = Effect.gen(function*() {
const context = yield* Effect.context<never>()
return PrometheusMetrics.formatUnsafe(context, { prefix: "myapp" })
})
// => string

FormatOptions, HttpOptions & MetricNameMapper

Section titled “FormatOptions, HttpOptions & MetricNameMapper”
  • FormatOptions{ prefix?, metricNameMapper? }. prefix is sanitized and joined with _; metricNameMapper rewrites names before sanitization.
  • HttpOptions — extends FormatOptions with path? for the route served by layerHttp.
  • MetricNameMapper(name: string) => string.
import type { PrometheusMetrics } from "effect/unstable/observability"
// e.g. camelCase -> snake_case
const mapper: PrometheusMetrics.MetricNameMapper = (name) =>
name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
// mapper("httpRequests") => "http_requests"

  • Metrics — defining counters, gauges, histograms, and summaries.
  • Logging — the logging APIs whose output these exporters carry.
  • Tracing — creating spans with Effect.withSpan.
  • Making HTTP requests — the HttpClient the OTLP exporters require.
  • Serving APIs — wiring the HttpRouter used by the Prometheus endpoint.