| 1 | --- |
| 2 | title: "Part 6: Observability" |
| 3 | description: 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. |
| 4 | sidebar: |
| 5 | order: 6 |
| 6 | --- |
| 7 | |
| 8 | In [Part 5](/cloudflare/tutorial/part-5) you set up CI/CD. Now you'll watch |
| 9 | your Worker run in production. Every Alchemy runtime ships with |
| 10 | OpenTelemetry built in: each request is wrapped in an `http.server` |
| 11 | span and every `Effect.log` is a log record. Exporting them is |
| 12 | configured like everything else in Alchemy — a Layer, wired to |
| 13 | resources through the binding infrastructure. In this part you'll |
| 14 | provision the receiving end — an [Axiom](/axiom) dataset per signal |
| 15 | and a least-privilege ingest token — in the same Stack as the |
| 16 | Worker, then bind them with `Axiom.Telemetry`. |
| 17 | |
| 18 | :::note |
| 19 | Cloudflare also has built-in tracing for Workers (automatic runtime |
| 20 | spans, exported via |
| 21 | [ObservabilityDestination](/providers/cloudflare/workers/observabilitydestination)). |
| 22 | That system ingests spans only from the workerd runtime itself — it |
| 23 | has no OTLP ingest endpoint — so your application-level Effect spans |
| 24 | export directly to an OTLP backend instead. Axiom is the one we'll |
| 25 | use here; any OTLP endpoint works the same way. |
| 26 | ::: |
| 27 | |
| 28 | ## Add the Axiom provider |
| 29 | |
| 30 | Axiom resources deploy alongside your Cloudflare ones. Merge the |
| 31 | provider layers in `alchemy.run.ts`: |
| 32 | |
| 33 | ```diff lang="typescript" |
| 34 | // alchemy.run.ts |
| 35 | import * as Alchemy from "alchemy"; |
| 36 | +import * as Axiom from "alchemy/Axiom"; |
| 37 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 38 | import * as Effect from "effect/Effect"; |
| 39 | +import * as Layer from "effect/Layer"; |
| 40 | |
| 41 | export 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 | |
| 52 | If 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 | |
| 58 | A `Dataset` is Axiom's storage container. Following the same |
| 59 | one-resource-per-file pattern as the Bucket, create |
| 60 | `src/observability.ts`: |
| 61 | |
| 62 | ```typescript |
| 63 | // src/observability.ts |
| 64 | import * as Axiom from "alchemy/Axiom"; |
| 65 | |
| 66 | export const Traces = Axiom.Dataset("Traces", { |
| 67 | name: "myapp-traces", |
| 68 | kind: "otel:traces:v1", |
| 69 | }); |
| 70 | ``` |
| 71 | |
| 72 | The `kind` tells Axiom this dataset stores OTel trace data — it |
| 73 | determines the schema and how spans render in the Axiom UI. |
| 74 | |
| 75 | ## Add one for logs |
| 76 | |
| 77 | Each OTel signal gets its own dataset: |
| 78 | |
| 79 | ```diff lang="typescript" |
| 80 | // src/observability.ts |
| 81 | import * as Axiom from "alchemy/Axiom"; |
| 82 | |
| 83 | export 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 |
| 95 | Dataset names are global to your Axiom org, and renaming one is a |
| 96 | replacement that deletes its data. If you deploy multiple stages |
| 97 | (like the PR previews from Part 5), give each stage its own dataset — |
| 98 | see [per-stage datasets](/axiom/data/ingest#per-stage-datasets). |
| 99 | ::: |
| 100 | |
| 101 | ## Mint an ingest token |
| 102 | |
| 103 | An `ApiToken` scoped with `datasetCapabilities` can ingest into |
| 104 | exactly 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 | |
| 117 | Axiom returns the bearer value exactly once, at create time. Alchemy |
| 118 | captures it into `Ingest.token` as a `Redacted<string>` so it can be |
| 119 | wired 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 |
| 124 | binding from Part 2. Merge it into the Worker's `Effect.provide`: |
| 125 | |
| 126 | ```diff lang="typescript" |
| 127 | // src/worker.ts |
| 128 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 129 | +import * as Axiom from "alchemy/Axiom"; |
| 130 | import * as Effect from "effect/Effect"; |
| 131 | +import * as Layer from "effect/Layer"; |
| 132 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 133 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 134 | import { Bucket } from "./bucket.ts"; |
| 135 | +import { Ingest, Logs, Traces } from "./observability.ts"; |
| 136 | |
| 137 | export 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 | |
| 154 | Building the layer at deploy time *binds* each dataset's OTLP |
| 155 | endpoint and the ingest token onto the Worker — the token travels as |
| 156 | a secret binding, and because the values are resource Outputs, the |
| 157 | datasets and token deploy before the Worker, automatically. |
| 158 | |
| 159 | ## Deploy and generate a trace |
| 160 | |
| 161 | Deploy, then hit the Worker to produce telemetry: |
| 162 | |
| 163 | ```sh |
| 164 | bun alchemy deploy |
| 165 | |
| 166 | curl -X PUT https://myapp-worker-dev-you-abc123.workers.dev/hello.txt \ |
| 167 | -d 'Hello, observability!' |
| 168 | curl https://myapp-worker-dev-you-abc123.workers.dev/hello.txt |
| 169 | ``` |
| 170 | |
| 171 | Nothing in the handler code changed: the runtime builds the OTLP |
| 172 | exporters from the bound configuration for each request, and flushes |
| 173 | them when the request's scope closes — after the response is sent, |
| 174 | via `waitUntil`, so export never adds latency. |
| 175 | |
| 176 | ## See it in Axiom |
| 177 | |
| 178 | Open your Axiom org and query the `myapp-traces` dataset. Each |
| 179 | request appears as an `http.server` span carrying the method, URL, |
| 180 | status, and headers, with `service.name` set to the Worker's deployed |
| 181 | name. 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 | |
| 186 | The root span shows *that* a request happened. To see *where the |
| 187 | time 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 — |
| 202 | here, the request's `http.server` root — so the R2 read shows up as |
| 203 | a nested bar in the trace waterfall, with the object key attached as |
| 204 | an attribute. |
| 205 | |
| 206 | ## Log with context |
| 207 | |
| 208 | Logs emitted inside a span are exported with that span's trace and |
| 209 | span 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 | |
| 218 | Deploy again, make a few requests, and the waterfall in Axiom now |
| 219 | shows the nested `bucket.get` span with your log line attached. |
| 220 | |
| 221 | ## Swapping, stacking, or removing exporters |
| 222 | |
| 223 | Telemetry is just a Layer, so turning it off is removing |
| 224 | `Axiom.Telemetry` from the `Layer.mergeAll` — without an exporter |
| 225 | Layer, Effect's no-op tracer makes every span free. Because the |
| 226 | Axiom layer is sugar over the generic `Alchemy.Telemetry.layerOtlp` |
| 227 | binding layer, any OTLP backend slots into the same position — and |
| 228 | exporters *compose*, so shipping to Axiom and a second backend at |
| 229 | once is just another entry in the merge: |
| 230 | |
| 231 | ```typescript |
| 232 | import * as Alchemy from "alchemy"; |
| 233 | |
| 234 | Effect.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 | |
| 246 | Spans are serialized once and fanned out, so both backends see the |
| 247 | same trace and span ids. `Alchemy.Telemetry.layer(...)` goes one |
| 248 | step further and installs any custom exporter Layer. See |
| 249 | [Telemetry](/infrastructure-as-effects/telemetry) for the full |
| 250 | mechanics. |
| 251 | |
| 252 | ## Recap |
| 253 | |
| 254 | You'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 |