| 1 | --- |
| 2 | title: Ship Worker telemetry to Axiom |
| 3 | description: Declare Axiom datasets, a least-privilege ingest token, and monitors in the same Stack as the Worker that emits the telemetry — or let Cloudflare push Workers Logs to Axiom natively. |
| 4 | sidebar: |
| 5 | order: 16 |
| 6 | --- |
| 7 | |
| 8 | Effect already emits OpenTelemetry — every span, `Metric`, and |
| 9 | `logInfo` in your Worker is OTel data waiting for somewhere to go |
| 10 | (see [Observability](/testing/observability)). With alchemy the |
| 11 | receiving end is resources too: the Axiom datasets, the ingest |
| 12 | token, and the alert that pages you all live in the same Stack as |
| 13 | the Worker they observe. A retention or threshold change is a |
| 14 | reviewable diff, not a dashboard click. |
| 15 | |
| 16 | This guide wires a Worker's telemetry into Axiom two ways: |
| 17 | |
| 18 | 1. **From the Worker** — the `Axiom.Telemetry` binding layer; the |
| 19 | [built-in telemetry](/infrastructure-as-effects/telemetry) exports |
| 20 | your Effect spans, logs, and metrics directly. |
| 21 | 2. **From Cloudflare** — an `ObservabilityDestination` that pushes |
| 22 | Workers Logs telemetry to Axiom. |
| 23 | |
| 24 | If you also provide [`Cloudflare.Telemetry()`](/cloudflare/observability/workers-tracing) |
| 25 | to put Effect spans in the Cloudflare waterfall, omit `traces` on |
| 26 | `Axiom.Telemetry` — Effect has a single Tracer, and the Cloudflare |
| 27 | tracer wins. Use path 2 to export that same waterfall to Axiom. |
| 28 | |
| 29 | ## Register the Axiom provider |
| 30 | |
| 31 | Axiom resources deploy alongside your Cloudflare ones — merge the |
| 32 | provider layers in your Stack: |
| 33 | |
| 34 | ```typescript |
| 35 | // alchemy.run.ts |
| 36 | import * as Axiom from "alchemy/Axiom"; |
| 37 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 38 | import * as Layer from "effect/Layer"; |
| 39 | |
| 40 | providers: Layer.mergeAll(Cloudflare.providers(), Axiom.providers()), |
| 41 | ``` |
| 42 | |
| 43 | If you haven't connected an Axiom account yet, run through |
| 44 | [Axiom setup](/axiom/setup) first. The Axiom provider reads |
| 45 | `AXIOM_TOKEN` or a stored credential. |
| 46 | |
| 47 | ## Create a dataset per signal |
| 48 | |
| 49 | A `Dataset` is Axiom's top-level container. Pick a `kind` per OTEL |
| 50 | signal — it determines the schema and how the data renders in the |
| 51 | Axiom UI. Declare them in their own module so both the Stack and the |
| 52 | Worker can import them: |
| 53 | |
| 54 | ```typescript |
| 55 | // src/observability.ts |
| 56 | import * as Axiom from "alchemy/Axiom"; |
| 57 | |
| 58 | export const Traces = Axiom.Dataset("Traces", { |
| 59 | name: "app-traces", |
| 60 | kind: "otel:traces:v1", |
| 61 | }); |
| 62 | |
| 63 | export const Logs = Axiom.Dataset("Logs", { |
| 64 | name: "app-logs", |
| 65 | kind: "otel:logs:v1", |
| 66 | }); |
| 67 | |
| 68 | export const Metrics = Axiom.Dataset("Metrics", { |
| 69 | name: "app-metrics", |
| 70 | kind: "otel:metrics:v1", |
| 71 | }); |
| 72 | ``` |
| 73 | |
| 74 | `kind` cannot be changed after creation — updating it triggers a |
| 75 | replacement, which deletes the data — so pick it up front. Each |
| 76 | dataset exposes Axiom's OTLP/HTTP endpoints as output attributes |
| 77 | (`otelTracesEndpoint`, `otelLogsEndpoint`, `otelMetricsEndpoint`), |
| 78 | which is what makes the binding below a pure wiring exercise. |
| 79 | |
| 80 | ## Set retention |
| 81 | |
| 82 | Retention is a prop, so a retention change is a diff in the PR: |
| 83 | |
| 84 | ```typescript |
| 85 | export const Logs = Axiom.Dataset("Logs", { |
| 86 | name: "app-logs", |
| 87 | kind: "otel:logs:v1", |
| 88 | description: "Application logs from prod workers", |
| 89 | retentionDays: 30, |
| 90 | useRetentionPeriod: true, |
| 91 | }); |
| 92 | ``` |
| 93 | |
| 94 | `description`, `retentionDays`, and `useRetentionPeriod` all update |
| 95 | in place; only `name` and `kind` force a replacement. |
| 96 | |
| 97 | ## Mint a least-privilege ingest token |
| 98 | |
| 99 | An `ApiToken` scoped with `datasetCapabilities` can ingest into |
| 100 | exactly the datasets you list and nothing else: |
| 101 | |
| 102 | ```typescript |
| 103 | // src/observability.ts |
| 104 | export const Ingest = Axiom.ApiToken("Ingest", { |
| 105 | name: "prod-ingest", |
| 106 | description: "Worker OTEL ingest", |
| 107 | datasetCapabilities: { |
| 108 | "app-traces": { ingest: ["create"] }, |
| 109 | "app-logs": { ingest: ["create"] }, |
| 110 | "app-metrics": { ingest: ["create"] }, |
| 111 | }, |
| 112 | }); |
| 113 | ``` |
| 114 | |
| 115 | Axiom returns the bearer value exactly once, at create time. It's |
| 116 | captured into `Ingest.token` as a `Redacted<string>` and persisted |
| 117 | in resource state — treat state as sensitive. Tokens have no update |
| 118 | API: changing any prop replaces the token and mints a new bearer; |
| 119 | identical props never rotate it. |
| 120 | |
| 121 | ## Bind Axiom to the Worker |
| 122 | |
| 123 | Import the datasets and token into the Worker's module and provide |
| 124 | the `Axiom.Telemetry` binding layer on its constructor Effect, merged into |
| 125 | the single `Effect.provide` with the other binding layers. That's |
| 126 | the whole integration — every span, log record, and metric the |
| 127 | Worker produces is exported, flushed as each request completes: |
| 128 | |
| 129 | ```typescript |
| 130 | // src/worker.ts |
| 131 | import * as Axiom from "alchemy/Axiom"; |
| 132 | import { Ingest, Logs, Metrics, Traces } from "./observability.ts"; |
| 133 | |
| 134 | export default Cloudflare.Worker( |
| 135 | "Api", |
| 136 | { main: import.meta.url }, |
| 137 | Effect.gen(function* () { |
| 138 | // ... |
| 139 | }).pipe( |
| 140 | Effect.provide( |
| 141 | Layer.mergeAll( |
| 142 | Cloudflare.R2.ReadWriteBucketBinding, |
| 143 | Axiom.Telemetry({ |
| 144 | token: Ingest, |
| 145 | traces: Traces, |
| 146 | logs: Logs, |
| 147 | metrics: Metrics, |
| 148 | }), |
| 149 | ), |
| 150 | ), |
| 151 | ), |
| 152 | ); |
| 153 | ``` |
| 154 | |
| 155 | Building the layer binds each dataset's OTLP endpoint and the ingest |
| 156 | token onto the Worker: the endpoints bind as `plain_text` vars, and |
| 157 | the token's `Authorization` header — `Redacted` — binds as |
| 158 | `secret_text`, so the bearer never appears in plaintext Worker |
| 159 | config. Axiom routes ingest by an `X-Axiom-Dataset` header, which |
| 160 | the layer composes per signal from each dataset's name. |
| 161 | |
| 162 | ## Alternative: let Cloudflare push it |
| 163 | |
| 164 | If you'd rather ship no exporter code at all, a Workers |
| 165 | `ObservabilityDestination` is an account-level OTLP export: Cloudflare |
| 166 | pushes Workers Logs telemetry (traces, logs, or metrics) to an |
| 167 | external HTTPS collector via Logpush. Point one at the Axiom dataset's |
| 168 | OTLP endpoint: |
| 169 | |
| 170 | ```typescript |
| 171 | // in the Stack's generator |
| 172 | import * as Output from "alchemy/Output"; |
| 173 | import * as Redacted from "effect/Redacted"; |
| 174 | import { Ingest, Traces } from "./src/observability.ts"; |
| 175 | |
| 176 | const traces = yield* Traces; |
| 177 | const ingest = yield* Ingest; |
| 178 | |
| 179 | yield* Cloudflare.Workers.ObservabilityDestination("TracesExport", { |
| 180 | url: traces.otelTracesEndpoint, |
| 181 | headers: { |
| 182 | authorization: Output.map(ingest.token, (t) => `Bearer ${Redacted.value(t)}`), |
| 183 | "x-axiom-dataset": traces.name, |
| 184 | }, |
| 185 | logpushDataset: "opentelemetry-traces", |
| 186 | }); |
| 187 | ``` |
| 188 | |
| 189 | One destination exports one dataset — `opentelemetry-traces`, |
| 190 | `opentelemetry-logs`, or `opentelemetry-metrics` — and the dataset |
| 191 | (like the name) is fixed at creation; changing either replaces the |
| 192 | destination. `url`, `headers`, and `enabled` update in place. Note |
| 193 | the headers are stored in the destination's Cloudflare-side config, |
| 194 | so the token lives at Cloudflare too. |
| 195 | |
| 196 | Cloudflare verifies the endpoint with a preflight `POST` on create |
| 197 | and on **every** in-place update, so the collector must answer `2xx` |
| 198 | for updates to converge. If the collector rejects empty probe |
| 199 | payloads, set `skipPreflightCheck: true` to skip the create-time |
| 200 | probe (updates always preflight). |
| 201 | |
| 202 | Workers Logs is on by default, but Workers **Traces** are opt-in per |
| 203 | Worker: |
| 204 | |
| 205 | ```typescript |
| 206 | const worker = yield* Cloudflare.Worker("Api", { |
| 207 | main: "./src/worker.ts", |
| 208 | observability: { traces: { enabled: true } }, |
| 209 | }); |
| 210 | ``` |
| 211 | |
| 212 | ## Alert on what you ship |
| 213 | |
| 214 | Telemetry nobody watches is a storage bill. A `Notifier` is the |
| 215 | destination (Slack, email, PagerDuty, webhook); a `Monitor` is a |
| 216 | scheduled APL query that fires it: |
| 217 | |
| 218 | ```typescript |
| 219 | const slack = yield* Axiom.Notifier("ops-slack", { |
| 220 | name: "ops-channel", |
| 221 | properties: { |
| 222 | slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! }, |
| 223 | }, |
| 224 | }); |
| 225 | |
| 226 | yield* Axiom.Monitor("panics", { |
| 227 | name: "Service panic", |
| 228 | type: "MatchEvent", |
| 229 | aplQuery: `['app-logs'] | where message contains "panic:"`, |
| 230 | intervalMinutes: 1, |
| 231 | rangeMinutes: 1, |
| 232 | notifierIds: [slack.id], |
| 233 | }); |
| 234 | ``` |
| 235 | |
| 236 | `MatchEvent` fires for every matching event; `Threshold` and |
| 237 | `AnomalyDetection` monitors cover rate-based and baseline-deviation |
| 238 | alerting — see the [Axiom overview](/axiom) for those shapes. |
| 239 | Changing a monitor's `type` replaces it; everything else updates in |
| 240 | place. |
| 241 | |
| 242 | ## Sibling paths |
| 243 | |
| 244 | Two adjacent Cloudflare-native options, in brief: |
| 245 | |
| 246 | - **[Logpush Job](/providers/cloudflare/logpush/job)** — push raw |
| 247 | `workers_trace_events` (and other Cloudflare datasets) as batched |
| 248 | log files to R2, S3, GCS, or an HTTP endpoint. Bulk archival |
| 249 | rather than OTLP. |
| 250 | - **[Alerting NotificationPolicy](/providers/cloudflare/alerting/notificationpolicy)** — |
| 251 | alerts on Cloudflare platform events (certificate renewals, health |
| 252 | checks, …) delivered to email or a |
| 253 | [NotificationWebhook](/providers/cloudflare/alerting/notificationwebhook). |
| 254 | Complements Axiom monitors, which alert on your telemetry. |
| 255 | |
| 256 | ## Where next |
| 257 | |
| 258 | - [Axiom overview](/axiom) — dashboards, annotations, views, and the |
| 259 | full resource list. |
| 260 | - [Axiom setup](/axiom/setup) — credentials and profiles. |
| 261 | - [Telemetry](/infrastructure-as-effects/telemetry) — how the |
| 262 | built-in exporters work across every runtime. |
| 263 | - [Tutorial Part 6](/cloudflare/tutorial/part-6) — the step-by-step |
| 264 | version of this setup. |
| 265 | - [Observability](/testing/observability) — OTel across clouds and |
| 266 | the receiving end as resources. |
| 267 | - Reference: [Dataset](/providers/axiom/dataset) · |
| 268 | [ApiToken](/providers/axiom/apitoken) · |
| 269 | [Monitor](/providers/axiom/monitor) · |
| 270 | [Notifier](/providers/axiom/notifier) · |
| 271 | [ObservabilityDestination](/providers/cloudflare/workers/observabilitydestination) |