1---
2title: Ship Worker telemetry to Axiom
3description: 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.
4sidebar:
5 order: 16
6---
7
8Effect 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
11receiving end is resources too: the Axiom datasets, the ingest
12token, and the alert that pages you all live in the same Stack as
13the Worker they observe. A retention or threshold change is a
14reviewable diff, not a dashboard click.
15
16This guide wires a Worker's telemetry into Axiom two ways:
17
181. **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.
212. **From Cloudflare** — an `ObservabilityDestination` that pushes
22 Workers Logs telemetry to Axiom.
23
24If you also provide [`Cloudflare.Telemetry()`](/cloudflare/observability/workers-tracing)
25to put Effect spans in the Cloudflare waterfall, omit `traces` on
26`Axiom.Telemetry` — Effect has a single Tracer, and the Cloudflare
27tracer wins. Use path 2 to export that same waterfall to Axiom.
28
29## Register the Axiom provider
30
31Axiom resources deploy alongside your Cloudflare ones — merge the
32provider layers in your Stack:
33
34```typescript
35// alchemy.run.ts
36import * as Axiom from "alchemy/Axiom";
37import * as Cloudflare from "alchemy/Cloudflare";
38import * as Layer from "effect/Layer";
39
40providers: Layer.mergeAll(Cloudflare.providers(), Axiom.providers()),
41```
42
43If 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
49A `Dataset` is Axiom's top-level container. Pick a `kind` per OTEL
50signal — it determines the schema and how the data renders in the
51Axiom UI. Declare them in their own module so both the Stack and the
52Worker can import them:
53
54```typescript
55// src/observability.ts
56import * as Axiom from "alchemy/Axiom";
57
58export const Traces = Axiom.Dataset("Traces", {
59 name: "app-traces",
60 kind: "otel:traces:v1",
61});
62
63export const Logs = Axiom.Dataset("Logs", {
64 name: "app-logs",
65 kind: "otel:logs:v1",
66});
67
68export 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
75replacement, which deletes the data — so pick it up front. Each
76dataset exposes Axiom's OTLP/HTTP endpoints as output attributes
77(`otelTracesEndpoint`, `otelLogsEndpoint`, `otelMetricsEndpoint`),
78which is what makes the binding below a pure wiring exercise.
79
80## Set retention
81
82Retention is a prop, so a retention change is a diff in the PR:
83
84```typescript
85export 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
95in place; only `name` and `kind` force a replacement.
96
97## Mint a least-privilege ingest token
98
99An `ApiToken` scoped with `datasetCapabilities` can ingest into
100exactly the datasets you list and nothing else:
101
102```typescript
103// src/observability.ts
104export 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
115Axiom returns the bearer value exactly once, at create time. It's
116captured into `Ingest.token` as a `Redacted<string>` and persisted
117in resource state — treat state as sensitive. Tokens have no update
118API: changing any prop replaces the token and mints a new bearer;
119identical props never rotate it.
120
121## Bind Axiom to the Worker
122
123Import the datasets and token into the Worker's module and provide
124the `Axiom.Telemetry` binding layer on its constructor Effect, merged into
125the single `Effect.provide` with the other binding layers. That's
126the whole integration — every span, log record, and metric the
127Worker produces is exported, flushed as each request completes:
128
129```typescript
130// src/worker.ts
131import * as Axiom from "alchemy/Axiom";
132import { Ingest, Logs, Metrics, Traces } from "./observability.ts";
133
134export 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
155Building the layer binds each dataset's OTLP endpoint and the ingest
156token onto the Worker: the endpoints bind as `plain_text` vars, and
157the token's `Authorization` header — `Redacted` — binds as
158`secret_text`, so the bearer never appears in plaintext Worker
159config. Axiom routes ingest by an `X-Axiom-Dataset` header, which
160the layer composes per signal from each dataset's name.
161
162## Alternative: let Cloudflare push it
163
164If you'd rather ship no exporter code at all, a Workers
165`ObservabilityDestination` is an account-level OTLP export: Cloudflare
166pushes Workers Logs telemetry (traces, logs, or metrics) to an
167external HTTPS collector via Logpush. Point one at the Axiom dataset's
168OTLP endpoint:
169
170```typescript
171// in the Stack's generator
172import * as Output from "alchemy/Output";
173import * as Redacted from "effect/Redacted";
174import { Ingest, Traces } from "./src/observability.ts";
175
176const traces = yield* Traces;
177const ingest = yield* Ingest;
178
179yield* 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
189One 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
192destination. `url`, `headers`, and `enabled` update in place. Note
193the headers are stored in the destination's Cloudflare-side config,
194so the token lives at Cloudflare too.
195
196Cloudflare verifies the endpoint with a preflight `POST` on create
197and on **every** in-place update, so the collector must answer `2xx`
198for updates to converge. If the collector rejects empty probe
199payloads, set `skipPreflightCheck: true` to skip the create-time
200probe (updates always preflight).
201
202Workers Logs is on by default, but Workers **Traces** are opt-in per
203Worker:
204
205```typescript
206const 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
214Telemetry nobody watches is a storage bill. A `Notifier` is the
215destination (Slack, email, PagerDuty, webhook); a `Monitor` is a
216scheduled APL query that fires it:
217
218```typescript
219const slack = yield* Axiom.Notifier("ops-slack", {
220 name: "ops-channel",
221 properties: {
222 slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! },
223 },
224});
225
226yield* 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
238alerting — see the [Axiom overview](/axiom) for those shapes.
239Changing a monitor's `type` replaces it; everything else updates in
240place.
241
242## Sibling paths
243
244Two 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)