| 1 | --- |
| 2 | title: Telemetry |
| 3 | description: 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 | |
| 6 | Every Alchemy runtime ships with OpenTelemetry export built in. |
| 7 | Each fetch, queue message, cron fire, RPC call, Durable Object call, |
| 8 | Workflow run, and Lambda invocation is wrapped in the machinery to |
| 9 | export 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 |
| 12 | Layer, configured like everything else in Alchemy. |
| 13 | |
| 14 | ## Provide an exporter layer |
| 15 | |
| 16 | Telemetry is configured by providing a Layer on the Runtime's |
| 17 | constructor Effect, merged into the single `Effect.provide` alongside the |
| 18 | other binding layers: |
| 19 | |
| 20 | ```typescript |
| 21 | import * as Alchemy from "alchemy"; |
| 22 | import * as Axiom from "alchemy/Axiom"; |
| 23 | |
| 24 | export 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 | |
| 40 | Without a telemetry layer, nothing exports — Effect's default tracer |
| 41 | is a no-op, so all instrumentation stays free. |
| 42 | |
| 43 | On Cloudflare, [`Cloudflare.Telemetry()`](/cloudflare/observability/workers-tracing) |
| 44 | is the platform-native alternative: it mirrors Effect spans into |
| 45 | Workers Observability (fetch/KV/R2/D1 auto-spans included) instead of |
| 46 | shipping 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 |
| 51 | any OTLP/HTTP backend. Its url and header values accept plain values |
| 52 | or resource Outputs — at deploy time, building the layer binds them |
| 53 | onto the host (`Redacted` values as secret bindings), and at runtime |
| 54 | the exporter reads them back: |
| 55 | |
| 56 | ```typescript |
| 57 | Alchemy.Telemetry.layerOtlp({ |
| 58 | url: "https://api.honeycomb.io", |
| 59 | headers: { "x-honeycomb-team": apiKey }, |
| 60 | }) |
| 61 | ``` |
| 62 | |
| 63 | Each signal can also be configured independently — a per-signal |
| 64 | entry takes precedence over the base `url` (which gets |
| 65 | `/v1/{traces,logs,metrics}` appended): |
| 66 | |
| 67 | ```typescript |
| 68 | Alchemy.Telemetry.layerOtlp({ |
| 69 | traces: { url: collector.tracesUrl, headers: { ... } }, |
| 70 | logs: { url: collector.logsUrl, headers: { ... } }, |
| 71 | }) |
| 72 | ``` |
| 73 | |
| 74 | Vendor 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 |
| 78 | walkthrough. |
| 79 | |
| 80 | For a manually-configured backend — a url and an API key — resolve |
| 81 | the secret with `Config` in the layer's constructor and pass it as a header. |
| 82 | The `Config` read binds the value at deploy time, and because it is |
| 83 | `Redacted` it travels as a secret binding: |
| 84 | |
| 85 | ```typescript |
| 86 | const 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 | |
| 99 | Exporter layers compose: merge as many as you need and every |
| 100 | destination receives the telemetry. Telemetry is serialized once and |
| 101 | fanned out per signal, so trace and span ids agree across |
| 102 | destinations — the same trace is queryable in both backends: |
| 103 | |
| 104 | ```typescript |
| 105 | Effect.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 | |
| 117 | Each destination only receives the signals it configures — here Axiom |
| 118 | gets traces and logs while the second collector gets all three. A |
| 119 | destination that fails to accept an export is logged and skipped; one |
| 120 | healthy destination keeps the exporter alive. |
| 121 | |
| 122 | ## Service identity |
| 123 | |
| 124 | The exported `service.name` defaults to the deployed Function/Worker's |
| 125 | physical name, then the stack name; set `serviceName` on the layer to |
| 126 | override it. Every signal also carries `alchemy.stack` and |
| 127 | `alchemy.stage` resource attributes, so one backend can receive every |
| 128 | stage and still tell them apart. |
| 129 | |
| 130 | ## Per-event scope and flushing |
| 131 | |
| 132 | On the per-event runtimes — Worker fetch/queue/cron/RPC, Durable |
| 133 | Object calls, Workflow runs, Lambda invocations — the exporters are |
| 134 | built into each event's request scope and buffered telemetry is |
| 135 | flushed 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 | |
| 142 | This per-event lifecycle is what makes export work on workerd, where |
| 143 | timers and fetches are pinned to the request that created them: a |
| 144 | process-global batching exporter would be killed the moment its |
| 145 | creating request ended. |
| 146 | |
| 147 | Server runtimes — Cloudflare Containers, ECS Tasks, EC2 hosts, |
| 148 | Lambda microVMs — are long-lived processes, so they build the |
| 149 | exporters once at startup instead: telemetry batches on the standard |
| 150 | export intervals and the final flush runs at graceful shutdown. |
| 151 | |
| 152 | ## HTTP request spans |
| 153 | |
| 154 | Fetch handlers get an `http.server` root span automatically, carrying |
| 155 | the method, URL, status, and headers. Incoming `traceparent` headers |
| 156 | are honored, so a trace that starts in one service continues through |
| 157 | your Worker or Lambda — and outgoing requests made with Effect's |
| 158 | `HttpClient` propagate the context onward. |
| 159 | |
| 160 | Everything you instrument nests under that root: |
| 161 | |
| 162 | ```typescript |
| 163 | const 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 |
| 174 | Layer is built per event into the event's request scope, so a custom |
| 175 | exporter gets the same flush-on-close behavior as the built-in one. |
| 176 | |
| 177 | Custom layers also compose with the OTLP destinations and with each |
| 178 | other: loggers and metric exporters merge, while a custom `Tracer` — |
| 179 | a single Effect service — replaces the built-in one. |
| 180 | |
| 181 | :::caution |
| 182 | A custom Layer for per-event runtimes should disable or greatly |
| 183 | lengthen its periodic export intervals (e.g. |
| 184 | `exportInterval: "1 hour"`). An interval export that fires mid-event |
| 185 | races the scope close — the close interrupts the in-flight batch and |
| 186 | drops it. The built-in exporter already handles this; the flush on |
| 187 | scope close delivers everything. |
| 188 | ::: |
| 189 | |
| 190 | ## Under the hood |
| 191 | |
| 192 | The binding travels as environment bindings on the host: each |
| 193 | `layerOtlp` layer appends its destination to a single bound |
| 194 | destination list (secrets through the secret channel), and the |
| 195 | runtime exporter reads the list back per event. An environment that |
| 196 | sets the standard `OTEL_EXPORTER_OTLP_*` variables contributes one |
| 197 | implicit extra destination — useful when the platform injects OTLP |
| 198 | settings 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 |