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.
Common case: one collector for everything
Section titled “Common case: one collector for everything”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)Choosing a serializer
Section titled “Choosing a serializer”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).
Wiring signals individually
Section titled “Wiring signals individually”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))Already using @effect/opentelemetry?
Section titled “Already using @effect/opentelemetry?”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.
OTLP reference
Section titled “OTLP reference”Otlp.layer
Section titled “Otlp.layer”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>Otlp.layerJson & Otlp.layerProtobuf
Section titled “Otlp.layerJson & Otlp.layerProtobuf”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>OtlpTracer.layer & OtlpTracer.make
Section titled “OtlpTracer.layer & OtlpTracer.make”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.TraceDataOtlpLogger.layer & OtlpLogger.make
Section titled “OtlpLogger.layer & OtlpLogger.make”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: ... }).
OtlpMetrics.layer & OtlpMetrics.make
Section titled “OtlpMetrics.layer & OtlpMetrics.make”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: ... }).
OtlpMetrics.AggregationTemporality
Section titled “OtlpMetrics.AggregationTemporality”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"OtlpSerialization
Section titled “OtlpSerialization”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 withHttpBody.jsonUnsafe(human-readable; good for debugging and OTLP/HTTP JSON endpoints).OtlpSerialization.layerProtobuf— encodes binary OTLP protobuf withapplication/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.layerJsonconst 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)})OtlpResource
Section titled “OtlpResource”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).
OtlpExporter.make
Section titled “OtlpExporter.make”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>Prometheus
Section titled “Prometheus”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.
PrometheusMetrics.layerHttp
Section titled “PrometheusMetrics.layerHttp”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/metricsPrometheusMetrics.format
Section titled “PrometheusMetrics.format”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"PrometheusMetrics.formatUnsafe
Section titled “PrometheusMetrics.formatUnsafe”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" })})// => stringFormatOptions, HttpOptions & MetricNameMapper
Section titled “FormatOptions, HttpOptions & MetricNameMapper”FormatOptions—{ prefix?, metricNameMapper? }.prefixis sanitized and joined with_;metricNameMapperrewrites names before sanitization.HttpOptions— extendsFormatOptionswithpath?for the route served bylayerHttp.MetricNameMapper—(name: string) => string.
import type { PrometheusMetrics } from "effect/unstable/observability"
// e.g. camelCase -> snake_caseconst mapper: PrometheusMetrics.MetricNameMapper = (name) => name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()// mapper("httpRequests") => "http_requests"See also
Section titled “See also”- 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
HttpClientthe OTLP exporters require. - Serving APIs — wiring the
HttpRouterused by the Prometheus endpoint.