1---
2title: "Part 6: Observability"
3description: Ship your Worker traces and logs to Axiom — datasets, an ingest token, and the Axiom.Telemetry binding layer declared in the same Stack as the Worker they observe.
4sidebar:
5 order: 6
6---
7
8In [Part 5](/cloudflare/tutorial/part-5) you set up CI/CD. Now you'll watch
9your Worker run in production. Every Alchemy runtime ships with
10OpenTelemetry built in: each request is wrapped in an `http.server`
11span and every `Effect.log` is a log record. Exporting them is
12configured like everything else in Alchemy — a Layer, wired to
13resources through the binding infrastructure. In this part you'll
14provision the receiving end — an [Axiom](/axiom) dataset per signal
15and a least-privilege ingest token — in the same Stack as the
16Worker, then bind them with `Axiom.Telemetry`.
17
18:::note
19Cloudflare also has built-in tracing for Workers (automatic runtime
20spans, exported via
21[ObservabilityDestination](/providers/cloudflare/workers/observabilitydestination)).
22That system ingests spans only from the workerd runtime itself — it
23has no OTLP ingest endpoint — so your application-level Effect spans
24export directly to an OTLP backend instead. Axiom is the one we'll
25use here; any OTLP endpoint works the same way.
26:::
27
28## Add the Axiom provider
29
30Axiom resources deploy alongside your Cloudflare ones. Merge the
31provider layers in `alchemy.run.ts`:
32
33```diff lang="typescript"
34// alchemy.run.ts
35import * as Alchemy from "alchemy";
36+import * as Axiom from "alchemy/Axiom";
37import * as Cloudflare from "alchemy/Cloudflare";
38import * as Effect from "effect/Effect";
39+import * as Layer from "effect/Layer";
40
41export default Alchemy.Stack(
42 "MyApp",
43 {
44- providers: Cloudflare.providers(),
45+ providers: Layer.mergeAll(Cloudflare.providers(), Axiom.providers()),
46 state: Cloudflare.state(),
47 },
48 // ...
49);
50```
51
52If you haven't connected an Axiom account yet, run through
53[Axiom setup](/axiom/setup) first. The Axiom provider reads
54`AXIOM_TOKEN` or a stored credential.
55
56## Create a dataset for traces
57
58A `Dataset` is Axiom's storage container. Following the same
59one-resource-per-file pattern as the Bucket, create
60`src/observability.ts`:
61
62```typescript
63// src/observability.ts
64import * as Axiom from "alchemy/Axiom";
65
66export const Traces = Axiom.Dataset("Traces", {
67 name: "myapp-traces",
68 kind: "otel:traces:v1",
69});
70```
71
72The `kind` tells Axiom this dataset stores OTel trace data — it
73determines the schema and how spans render in the Axiom UI.
74
75## Add one for logs
76
77Each OTel signal gets its own dataset:
78
79```diff lang="typescript"
80// src/observability.ts
81import * as Axiom from "alchemy/Axiom";
82
83export const Traces = Axiom.Dataset("Traces", {
84 name: "myapp-traces",
85 kind: "otel:traces:v1",
86});
87
88+export const Logs = Axiom.Dataset("Logs", {
89+ name: "myapp-logs",
90+ kind: "otel:logs:v1",
91+});
92```
93
94:::note
95Dataset names are global to your Axiom org, and renaming one is a
96replacement that deletes its data. If you deploy multiple stages
97(like the PR previews from Part 5), give each stage its own dataset —
98see [per-stage datasets](/axiom/data/ingest#per-stage-datasets).
99:::
100
101## Mint an ingest token
102
103An `ApiToken` scoped with `datasetCapabilities` can ingest into
104exactly the datasets you list and nothing else:
105
106```diff lang="typescript"
107// src/observability.ts
108+export const Ingest = Axiom.ApiToken("Ingest", {
109+ name: "myapp-ingest",
110+ datasetCapabilities: {
111+ "myapp-traces": { ingest: ["create"] },
112+ "myapp-logs": { ingest: ["create"] },
113+ },
114+});
115```
116
117Axiom returns the bearer value exactly once, at create time. Alchemy
118captures it into `Ingest.token` as a `Redacted<string>` so it can be
119wired into consumers without ever appearing in plaintext.
120
121## Bind Axiom to the Worker
122
123`Axiom.Telemetry` is a binding layer, provided exactly like the R2
124binding from Part 2. Merge it into the Worker's `Effect.provide`:
125
126```diff lang="typescript"
127// src/worker.ts
128import * as Cloudflare from "alchemy/Cloudflare";
129+import * as Axiom from "alchemy/Axiom";
130import * as Effect from "effect/Effect";
131+import * as Layer from "effect/Layer";
132import { HttpServerRequest } from "effect/http/HttpServerRequest";
133import * as HttpServerResponse from "effect/http/HttpServerResponse";
134import { Bucket } from "./bucket.ts";
135+import { Ingest, Logs, Traces } from "./observability.ts";
136
137export default Cloudflare.Worker(
138 "Worker",
139 { main: import.meta.url },
140 Effect.gen(function* () {
141 // ...
142- }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
143+ }).pipe(
144+ Effect.provide(
145+ Layer.mergeAll(
146+ Cloudflare.R2.ReadWriteBucketBinding,
147+ Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
148+ ),
149+ ),
150+ ),
151);
152```
153
154Building the layer at deploy time *binds* each dataset's OTLP
155endpoint and the ingest token onto the Worker — the token travels as
156a secret binding, and because the values are resource Outputs, the
157datasets and token deploy before the Worker, automatically.
158
159## Deploy and generate a trace
160
161Deploy, then hit the Worker to produce telemetry:
162
163```sh
164bun alchemy deploy
165
166curl -X PUT https://myapp-worker-dev-you-abc123.workers.dev/hello.txt \
167 -d 'Hello, observability!'
168curl https://myapp-worker-dev-you-abc123.workers.dev/hello.txt
169```
170
171Nothing in the handler code changed: the runtime builds the OTLP
172exporters from the bound configuration for each request, and flushes
173them when the request's scope closes — after the response is sent,
174via `waitUntil`, so export never adds latency.
175
176## See it in Axiom
177
178Open your Axiom org and query the `myapp-traces` dataset. Each
179request appears as an `http.server` span carrying the method, URL,
180status, and headers, with `service.name` set to the Worker's deployed
181name. The `myapp-logs` dataset has a log record for every
182`Effect.log` your handler ran, linked to its span.
183
184## Add a custom span
185
186The root span shows *that* a request happened. To see *where the
187time went*, wrap the R2 read in its own span:
188
189```diff lang="typescript"
190// src/worker.ts
191 if (request.method === "PUT") {
192 // ...
193 }
194
195- const object = yield* bucket.get(key);
196+ const object = yield* bucket
197+ .get(key)
198+ .pipe(Effect.withSpan("bucket.get", { attributes: { key } }));
199```
200
201`Effect.withSpan` opens a child span under whatever span is current —
202here, the request's `http.server` root — so the R2 read shows up as
203a nested bar in the trace waterfall, with the object key attached as
204an attribute.
205
206## Log with context
207
208Logs emitted inside a span are exported with that span's trace and
209span IDs, so Axiom can jump from a log line to its trace:
210
211```diff lang="typescript"
212// src/worker.ts
213 const text = yield* object.text();
214+ yield* Effect.log("served object", { key });
215 return HttpServerResponse.text(text);
216```
217
218Deploy again, make a few requests, and the waterfall in Axiom now
219shows the nested `bucket.get` span with your log line attached.
220
221## Swapping, stacking, or removing exporters
222
223Telemetry is just a Layer, so turning it off is removing
224`Axiom.Telemetry` from the `Layer.mergeAll` — without an exporter
225Layer, Effect's no-op tracer makes every span free. Because the
226Axiom layer is sugar over the generic `Alchemy.Telemetry.layerOtlp`
227binding layer, any OTLP backend slots into the same position — and
228exporters *compose*, so shipping to Axiom and a second backend at
229once is just another entry in the merge:
230
231```typescript
232import * as Alchemy from "alchemy";
233
234Effect.provide(
235 Layer.mergeAll(
236 Cloudflare.R2.ReadWriteBucketBinding,
237 Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
238 Alchemy.Telemetry.layerOtlp({
239 url: "https://collector.example.com",
240 headers: { "x-api-key": apiKey },
241 }),
242 ),
243)
244```
245
246Spans are serialized once and fanned out, so both backends see the
247same trace and span ids. `Alchemy.Telemetry.layer(...)` goes one
248step further and installs any custom exporter Layer. See
249[Telemetry](/infrastructure-as-effects/telemetry) for the full
250mechanics.
251
252## Recap
253
254You've completed the tutorial. You now know how to:
255
256- **Part 1** — Create a Stack and deploy a resource
257- **Part 2** — Add a Worker with bindings to other resources
258- **Part 3** — Write integration tests against deployed stacks
259- **Part 4** — Run locally with `alchemy dev`
260- **Part 5** — Mint scoped CI credentials and deploy from Actions
261- **Part 6** — Ship traces and logs to Axiom with datasets, an
262 ingest token, and the Axiom.Telemetry binding layer declared next
263
264## What's next
265
266- [What is Alchemy?](/what-is-alchemy) — the model
267 behind everything you just built: Runtimes, Bindings, Layers, and
268 the two phases
269- [Telemetry](/infrastructure-as-effects/telemetry) — how the
270 built-in telemetry works across every runtime (Workers, Durable
271 Objects, Workflows, Lambda, containers)
272- [Alerting](/axiom/guides/alerting) — monitors and notifiers over
273 the data you just ingested, as resources in the same Stack
274- [Dashboards](/axiom/guides/dashboards) — charts over your traces
275 and logs, declared in code
276- [Ship Worker telemetry to Axiom](/cloudflare/observability/axiom-observability) —
277 the reference version of this setup, including the
278 Cloudflare-native push path