1---
2title: Native Workers tracing
3description: Mirror Effect spans into Cloudflare Workers Observability with Cloudflare.Telemetry() — the same Layer shape as Axiom.Telemetry, with Cloudflare owning sampling and export.
4sidebar:
5 order: 15
6---
7
8Cloudflare Workers can emit OpenTelemetry traces natively: fetch, KV, R2,
9and D1 are auto-instrumented, and custom spans join that waterfall via
10`tracing.startActiveSpan`. Alchemy's `Cloudflare.Telemetry()` Layer
11forwards `Effect.withSpan` / `Effect.fn` frames into that API so Effect
12code shows up next to the platform spans.
13
14## Provide the layer
15
16Pin a compatibility date at or after `2026-07-28` (`startActiveSpan` GA).
17The global Worker default is still older, so omit the date and deploy
18fails instead of silently dropping Effect spans.
19
20```typescript
21export default Cloudflare.Worker(
22 "Worker",
23 {
24 main: import.meta.url,
25 compatibility: { date: "2026-08-25" },
26 },
27 Effect.gen(function* () {
28 return {
29 fetch: Effect.gen(function* () {
30 yield* doWork().pipe(Effect.withSpan("operation"));
31 return HttpServerResponse.text("ok");
32 }),
33 };
34 }).pipe(
35 Effect.provide(
36 Layer.mergeAll(
37 Cloudflare.R2.ReadWriteBucketBinding,
38 Cloudflare.Telemetry(),
39 ),
40 ),
41 ),
42);
43```
44
45You do **not** set `observability.traces.enabled` on the Worker, write a
46Wrangler `observability` block, or configure an OTLP URL. The Layer
47enables traces on the host (the same bind path as `Cloudflare.cache()`)
48and installs a per-event Effect Tracer. Cloudflare owns sampling and
49export — spans submit with the request, no `waitUntil` flush.
50
51Existing `Effect.withSpan` / `Effect.fn` instrumentation does not change.
52
53During `alchemy dev` the compatibility date is not gated and the Layer
54has no deploy-time effect; whether spans are recorded locally is up to
55the local runtime. A Worker opted out of local emulation with
56`Alchemy.remote()` traces normally.
57
58## What you get
59
60### Effect spans in the platform waterfall
61
62`Effect.withSpan` frames nest with Cloudflare's own spans. A KV read
63inside a span shows up as its child, even when the fiber that issued it
64was forked or resumed on a later tick.
65
66```typescript
67const handle = Effect.gen(function* () {
68 const user = yield* kv.get("user:1").pipe(Effect.withSpan("load.user"));
69 yield* Effect.all([enrich(user), audit(user)], { concurrency: "unbounded" });
70}).pipe(Effect.withSpan("handle"));
71```
72
73```text
74http.server GET
75└─ handle
76 ├─ load.user
77 │ └─ kv_get
78 ├─ enrich
79 └─ audit
80```
81
82### Attributes
83
84Scalar annotations (`string`, `number`, `boolean`) are forwarded to the
85Cloudflare span. Nested objects, span events, and links stay Effect-local.
86
87```typescript
88yield* Effect.annotateCurrentSpan("user.id", userId); // forwarded
89yield* Effect.annotateCurrentSpan("retries", 2); // forwarded
90yield* Effect.annotateCurrentSpan("cached", true); // forwarded
91yield* Effect.annotateCurrentSpan("input", { nested }); // Effect-local
92```
93
94### Outcome
95
96Cloudflare has no outcome setter yet, so completion is recorded as the
97`effect.exit` attribute: `success`, `failure`, or `interrupted`.
98
99```typescript
100yield* Effect.fail(new Error("boom")).pipe(
101 Effect.withSpan("failing"), // effect.exit = "failure"
102 Effect.ignore,
103);
104const fiber = yield* Effect.forkChild(
105 Effect.sleep("30 seconds").pipe(Effect.withSpan("slow")),
106);
107yield* Fiber.interrupt(fiber); // effect.exit = "interrupted"
108```
109
110### Sampling and persistence
111
112`headSamplingRate` and `persist` are passed straight through to the
113Worker's `observability.traces`. Omit them to keep Cloudflare's defaults.
114Cloudflare applies the sampling rate when it ingests the trace, so every
115invocation still sees a live `Tracer`.
116
117```typescript
118Cloudflare.Telemetry({
119 headSamplingRate: 0.1, // keep 10% of invocations
120 persist: false, // export only, nothing in the dashboard
121})
122```
123
124## Combining with other exporters
125
126Effect has a single `Tracer`, and `Cloudflare.Telemetry()` provides it.
127Any other exporter you merge in keeps its loggers and metric exporters;
128only its `traces` are superseded. With Axiom, for example, leave `traces`
129off and the logs and metrics still flow, in either `Layer.mergeAll`
130order:
131
132```typescript
133Effect.provide(
134 Layer.mergeAll(
135 Cloudflare.Telemetry(),
136 Axiom.Telemetry({ token: Ingest, logs: Logs, metrics: Metrics }),
137 ),
138)
139```
140
141The same goes for the generic `Alchemy.Telemetry.layerOtlp({ logs, metrics })`.
142
143To ship the waterfall (Effect + platform spans) to another backend,
144export it from Cloudflare with an
145[`ObservabilityDestination`](/cloudflare/observability/axiom-observability)
146rather than sending a second Worker-side OTLP trace stream.
147
148## Async Workers
149
150Non-Effect Workers keep the `observability` prop — there is no Tracer to
151install:
152
153```typescript
154yield* Cloudflare.Worker("Api", {
155 main: "./src/api.ts",
156 observability: {
157 traces: { enabled: true },
158 },
159});
160```