# OTLP & Prometheus exporters

Effect ships its own OpenTelemetry exporters in `effect/unstable/observability`. These
modules turn Effect's built-in [logs](https://effect.plants.sh/observability/logging/), [traces](https://effect.plants.sh/observability/tracing/),
and [metrics](https://effect.plants.sh/observability/metrics/) into OTLP/HTTP payloads (or a Prometheus
scrape body) and push them to a collector — with no extra OpenTelemetry SDK runtime
required.
**Unstable module:** These modules live under `effect/unstable/observability` and may change between minor
releases. Import them as ``. The
OTLP exporters require an `HttpClient` (provide `FetchHttpClient.layer` from
`effect/unstable/http`) and an `OtlpSerialization` service (JSON or protobuf). Unlike
`@effect/opentelemetry`, they need no additional OpenTelemetry runtime dependency.

## 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.

```ts
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:

```ts
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)
```
**service.name comes from config too:** If you omit `resource`, the exporters read `OTEL_SERVICE_NAME`,
`OTEL_SERVICE_VERSION`, and `OTEL_RESOURCE_ATTRIBUTES` from the environment via
`OtlpResource.fromConfig`. A `service.name` must be resolvable from one of these
sources — it is also used as the OTLP instrumentation scope name.

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

```ts
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

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:

```ts
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`?

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

### `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`.

```ts
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`

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`.

```ts
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`

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.

```ts
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:

```ts
import type { OtlpTracer } from "effect/unstable/observability"

// TraceData -> { resourceSpans: ResourceSpan[] }
// ResourceSpan -> { resource, scopeSpans: ScopeSpan[] }
// ScopeSpan -> { scope, spans }
declare const data: OtlpTracer.TraceData
```

### `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.

```ts
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`

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.

```ts
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`

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.

```ts
import type { OtlpMetrics } from "effect/unstable/observability"

const t: OtlpMetrics.AggregationTemporality = "delta"
// => "cumulative" | "delta"
```

### `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 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).

```ts
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)
})
```

### `OtlpResource`

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

`OtlpResource.make` constructs a resource from known metadata:

```ts
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.

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

```ts
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`:

```ts
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`

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.

```ts
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

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`

Adds a `GET /metrics` route to an [`HttpRouter`](https://effect.plants.sh/http-api/serving-and-clients/) 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.

```ts
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
```

### `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.

```ts
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`

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

```ts
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`

- `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`.

```ts
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"
```
**Metric type mapping:** Effect metric types map to Prometheus as: Counter → `counter`, Gauge → `gauge`,
Histogram → `histogram` (with `_bucket`/`_sum`/`_count` lines), Summary → `summary`
(quantile/`_sum`/`_count`), Frequency → `counter` with a `key` label per occurrence.
Metric attributes become labels — keep them low-cardinality.

---

## See also

- [Metrics](https://effect.plants.sh/observability/metrics/) — defining counters, gauges, histograms, and summaries.
- [Logging](https://effect.plants.sh/observability/logging/) — the logging APIs whose output these exporters carry.
- [Tracing](https://effect.plants.sh/observability/tracing/) — creating spans with `Effect.withSpan`.
- [Making HTTP requests](https://effect.plants.sh/http-client/making-requests/) — the `HttpClient` the OTLP exporters require.
- [Serving APIs](https://effect.plants.sh/http-api/serving-and-clients/) — wiring the `HttpRouter` used by the Prometheus endpoint.