1---
2title: Telemetry
3description: OpenTelemetry is built into every runtime — exporters are Layers, wired to resources through the binding infrastructure, built per event, and flushed as each request completes.
4---
5
6Every Alchemy runtime ships with OpenTelemetry export built in.
7Each fetch, queue message, cron fire, RPC call, Durable Object call,
8Workflow run, and Lambda invocation is wrapped in the machinery to
9export the traces, logs, and metrics that Effect already emits —
10`Effect.withSpan` and `Effect.fn` spans, `Effect.log` records, and
11`Metric` updates. There is no SDK to initialize: the exporter is a
12Layer, configured like everything else in Alchemy.
13
14## Provide an exporter layer
15
16Telemetry is configured by providing a Layer on the Runtime's
17constructor Effect, merged into the single `Effect.provide` alongside the
18other binding layers:
19
20```typescript
21import * as Alchemy from "alchemy";
22import * as Axiom from "alchemy/Axiom";
23
24export default Cloudflare.Worker(
25 "Worker",
26 { main: import.meta.url },
27 Effect.gen(function* () {
28 // ...
29 }).pipe(
30 Effect.provide(
31 Layer.mergeAll(
32 Cloudflare.R2.ReadWriteBucketBinding,
33 Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
34 ),
35 ),
36 ),
37);
38```
39
40Without a telemetry layer, nothing exports — Effect's default tracer
41is a no-op, so all instrumentation stays free.
42
43On Cloudflare, [`Cloudflare.Telemetry()`](/cloudflare/observability/workers-tracing)
44is the platform-native alternative: it mirrors Effect spans into
45Workers Observability (fetch/KV/R2/D1 auto-spans included) instead of
46shipping OTLP from the Worker. Cloudflare owns sampling and export.
47
48## Binding layers
49
50`Alchemy.Telemetry.layerOtlp` is the generic form: a *binding* layer for
51any OTLP/HTTP backend. Its url and header values accept plain values
52or resource Outputs — at deploy time, building the layer binds them
53onto the host (`Redacted` values as secret bindings), and at runtime
54the exporter reads them back:
55
56```typescript
57Alchemy.Telemetry.layerOtlp({
58 url: "https://api.honeycomb.io",
59 headers: { "x-honeycomb-team": apiKey },
60})
61```
62
63Each signal can also be configured independently — a per-signal
64entry takes precedence over the base `url` (which gets
65`/v1/{traces,logs,metrics}` appended):
66
67```typescript
68Alchemy.Telemetry.layerOtlp({
69 traces: { url: collector.tracesUrl, headers: { ... } },
70 logs: { url: collector.logsUrl, headers: { ... } },
71})
72```
73
74Vendor layers wrap this. [`Axiom.Telemetry`](/axiom) takes an ingest
75`ApiToken` and a `Dataset` per signal, and composes the endpoints and
76`Authorization`/`X-Axiom-Dataset` headers for you — see
77[Tutorial Part 6](/cloudflare/tutorial/part-6) for the end-to-end
78walkthrough.
79
80For a manually-configured backend — a url and an API key — resolve
81the secret with `Config` in the layer's constructor and pass it as a header.
82The `Config` read binds the value at deploy time, and because it is
83`Redacted` it travels as a secret binding:
84
85```typescript
86const Honeycomb = Layer.unwrap(
87 Effect.gen(function* () {
88 const key = yield* Config.Redacted("HONEYCOMB_API_KEY");
89 return Alchemy.Telemetry.layerOtlp({
90 url: "https://api.honeycomb.io",
91 headers: { "x-honeycomb-team": key },
92 });
93 }),
94);
95```
96
97## Multiple destinations
98
99Exporter layers compose: merge as many as you need and every
100destination receives the telemetry. Telemetry is serialized once and
101fanned out per signal, so trace and span ids agree across
102destinations — the same trace is queryable in both backends:
103
104```typescript
105Effect.provide(
106 Layer.mergeAll(
107 Cloudflare.R2.ReadWriteBucketBinding,
108 Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
109 Alchemy.Telemetry.layerOtlp({
110 url: "https://collector.internal.example.com",
111 headers: { "x-api-key": apiKey },
112 }),
113 ),
114)
115```
116
117Each destination only receives the signals it configures — here Axiom
118gets traces and logs while the second collector gets all three. A
119destination that fails to accept an export is logged and skipped; one
120healthy destination keeps the exporter alive.
121
122## Service identity
123
124The exported `service.name` defaults to the deployed Function/Worker's
125physical name, then the stack name; set `serviceName` on the layer to
126override it. Every signal also carries `alchemy.stack` and
127`alchemy.stage` resource attributes, so one backend can receive every
128stage and still tell them apart.
129
130## Per-event scope and flushing
131
132On the per-event runtimes — Worker fetch/queue/cron/RPC, Durable
133Object calls, Workflow runs, Lambda invocations — the exporters are
134built into each event's request scope and buffered telemetry is
135flushed when that scope closes:
136
137- On Cloudflare, the flush is registered with `ctx.waitUntil`, so it
138 happens after the response is sent and never adds latency.
139- On Lambda, the flush settles inline before the invocation returns —
140 the response isn't released until the invoke completes anyway.
141
142This per-event lifecycle is what makes export work on workerd, where
143timers and fetches are pinned to the request that created them: a
144process-global batching exporter would be killed the moment its
145creating request ended.
146
147Server runtimes — Cloudflare Containers, ECS Tasks, EC2 hosts,
148Lambda microVMs — are long-lived processes, so they build the
149exporters once at startup instead: telemetry batches on the standard
150export intervals and the final flush runs at graceful shutdown.
151
152## HTTP request spans
153
154Fetch handlers get an `http.server` root span automatically, carrying
155the method, URL, status, and headers. Incoming `traceparent` headers
156are honored, so a trace that starts in one service continues through
157your Worker or Lambda — and outgoing requests made with Effect's
158`HttpClient` propagate the context onward.
159
160Everything you instrument nests under that root:
161
162```typescript
163const object = yield* bucket
164 .get(key)
165 .pipe(Effect.withSpan("bucket.get", { attributes: { key } }));
166```
167
168## Custom exporters
169
170`Alchemy.Telemetry.layer` installs any Layer that provides a
171`Tracer`, loggers, or metric exporters — Effect's
172`effect/observability` modules (`OtlpTracer`, `OtlpLogger`,
173`OtlpMetrics`, protobuf serialization) compose here directly. The
174Layer is built per event into the event's request scope, so a custom
175exporter gets the same flush-on-close behavior as the built-in one.
176
177Custom layers also compose with the OTLP destinations and with each
178other: loggers and metric exporters merge, while a custom `Tracer` —
179a single Effect service — replaces the built-in one.
180
181:::caution
182A custom Layer for per-event runtimes should disable or greatly
183lengthen its periodic export intervals (e.g.
184`exportInterval: "1 hour"`). An interval export that fires mid-event
185races the scope close — the close interrupts the in-flight batch and
186drops it. The built-in exporter already handles this; the flush on
187scope close delivers everything.
188:::
189
190## Under the hood
191
192The binding travels as environment bindings on the host: each
193`layerOtlp` layer appends its destination to a single bound
194destination list (secrets through the secret channel), and the
195runtime exporter reads the list back per event. An environment that
196sets the standard `OTEL_EXPORTER_OTLP_*` variables contributes one
197implicit extra destination — useful when the platform injects OTLP
198settings for you.
199
200## Where next
201
202- [Tutorial Part 6](/cloudflare/tutorial/part-6) — bind a Worker's
203 telemetry to Axiom end to end
204- [Observability](/testing/observability) — provision the receiving
205 end (datasets, tokens, monitors, alarms) as resources
206- [Native Workers tracing](/cloudflare/observability/workers-tracing) —
207 `Cloudflare.Telemetry()`, Effect spans in the Cloudflare waterfall
208- [Ship Worker telemetry to Axiom](/cloudflare/observability/axiom-observability) —
209 the Cloudflare-specific reference, including the platform-push
210 alternative