Ship Worker telemetry to Axiom
Effect already emits OpenTelemetry — every span, Metric, and
logInfo in your Worker is OTel data waiting for somewhere to go
(see Observability). With alchemy the
receiving end is resources too: the Axiom datasets, the ingest
token, and the alert that pages you all live in the same Stack as
the Worker they observe. A retention or threshold change is a
reviewable diff, not a dashboard click.
This guide wires a Worker’s telemetry into Axiom two ways:
- From the Worker — the
Axiom.Telemetrybinding layer; the built-in telemetry exports your Effect spans, logs, and metrics directly. - From Cloudflare — an
ObservabilityDestinationthat pushes Workers Logs telemetry to Axiom.
Register the Axiom provider
Section titled “Register the Axiom provider”Axiom resources deploy alongside your Cloudflare ones — merge the provider layers in your Stack:
import * as Axiom from "alchemy/Axiom";import * as Cloudflare from "alchemy/Cloudflare";import * as Layer from "effect/Layer";
providers: Layer.mergeAll(Cloudflare.providers(), Axiom.providers()),If you haven’t connected an Axiom account yet, run through
Axiom setup first — alchemy login picks up
AXIOM_TOKEN or a stored credential.
Create a dataset per signal
Section titled “Create a dataset per signal”A Dataset is Axiom’s top-level container. Pick a kind per OTEL
signal — it determines the schema and how the data renders in the
Axiom UI. Declare them in their own module so both the Stack and the
Worker can import them:
import * as Axiom from "alchemy/Axiom";
export const Traces = Axiom.Dataset("Traces", { name: "app-traces", kind: "otel:traces:v1",});
export const Logs = Axiom.Dataset("Logs", { name: "app-logs", kind: "otel:logs:v1",});
export const Metrics = Axiom.Dataset("Metrics", { name: "app-metrics", kind: "otel:metrics:v1",});kind cannot be changed after creation — updating it triggers a
replacement, which deletes the data — so pick it up front. Each
dataset exposes Axiom’s OTLP/HTTP endpoints as output attributes
(otelTracesEndpoint, otelLogsEndpoint, otelMetricsEndpoint),
which is what makes the binding below a pure wiring exercise.
Set retention
Section titled “Set retention”Retention is a prop, so a retention change is a diff in the PR:
export const Logs = Axiom.Dataset("Logs", { name: "app-logs", kind: "otel:logs:v1", description: "Application logs from prod workers", retentionDays: 30, useRetentionPeriod: true,});description, retentionDays, and useRetentionPeriod all update
in place; only name and kind force a replacement.
Mint a least-privilege ingest token
Section titled “Mint a least-privilege ingest token”An ApiToken scoped with datasetCapabilities can ingest into
exactly the datasets you list and nothing else:
export const Ingest = Axiom.ApiToken("Ingest", { name: "prod-ingest", description: "Worker OTEL ingest", datasetCapabilities: { "app-traces": { ingest: ["create"] }, "app-logs": { ingest: ["create"] }, "app-metrics": { ingest: ["create"] }, },});Axiom returns the bearer value exactly once, at create time. It’s
captured into Ingest.token as a Redacted<string> and persisted
in resource state — treat state as sensitive. Tokens have no update
API: changing any prop replaces the token and mints a new bearer;
identical props never rotate it.
Bind Axiom to the Worker
Section titled “Bind Axiom to the Worker”Import the datasets and token into the Worker’s module and provide
the Axiom.Telemetry binding layer on its init Effect, merged into
the single Effect.provide with the other binding layers. That’s
the whole integration — every span, log record, and metric the
Worker produces is exported, flushed as each request completes:
import * as Axiom from "alchemy/Axiom";import { Ingest, Logs, Metrics, Traces } from "./observability.ts";
export default Cloudflare.Worker( "Api", { main: import.meta.url }, Effect.gen(function* () { // ... }).pipe( Effect.provide( Layer.mergeAll( Cloudflare.R2.ReadWriteBucketBinding, Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs, metrics: Metrics, }), ), ), ),);Building the layer binds each dataset’s OTLP endpoint and the ingest
token onto the Worker: the endpoints bind as plain_text vars, and
the token’s Authorization header — Redacted — binds as
secret_text, so the bearer never appears in plaintext Worker
config. Axiom routes ingest by an X-Axiom-Dataset header, which
the layer composes per signal from each dataset’s name.
Alternative: let Cloudflare push it
Section titled “Alternative: let Cloudflare push it”If you’d rather ship no exporter code at all, a Workers
ObservabilityDestination is an account-level OTLP export: Cloudflare
pushes Workers Logs telemetry (traces, logs, or metrics) to an
external HTTPS collector via Logpush. Point one at the Axiom dataset’s
OTLP endpoint:
// in the Stack's generatorimport * as Output from "alchemy/Output";import * as Redacted from "effect/Redacted";import { Ingest, Traces } from "./src/observability.ts";
const traces = yield* Traces;const ingest = yield* Ingest;
yield* Cloudflare.Workers.ObservabilityDestination("TracesExport", { url: traces.otelTracesEndpoint, headers: { authorization: Output.map(ingest.token, (t) => `Bearer ${Redacted.value(t)}`), "x-axiom-dataset": traces.name, }, logpushDataset: "opentelemetry-traces",});One destination exports one dataset — opentelemetry-traces,
opentelemetry-logs, or opentelemetry-metrics — and the dataset
(like the name) is fixed at creation; changing either replaces the
destination. url, headers, and enabled update in place. Note
the headers are stored in the destination’s Cloudflare-side config,
so the token lives at Cloudflare too.
Cloudflare verifies the endpoint with a preflight POST on create
and on every in-place update, so the collector must answer 2xx
for updates to converge. If the collector rejects empty probe
payloads, set skipPreflightCheck: true to skip the create-time
probe (updates always preflight).
Workers Logs is on by default, but Workers Traces are opt-in per Worker:
const worker = yield* Cloudflare.Worker("Api", { main: "./src/worker.ts", observability: { traces: { enabled: true } },});Alert on what you ship
Section titled “Alert on what you ship”Telemetry nobody watches is a storage bill. A Notifier is the
destination (Slack, email, PagerDuty, webhook); a Monitor is a
scheduled APL query that fires it:
const slack = yield* Axiom.Notifier("ops-slack", { name: "ops-channel", properties: { slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! }, },});
yield* Axiom.Monitor("panics", { name: "Service panic", type: "MatchEvent", aplQuery: `['app-logs'] | where message contains "panic:"`, intervalMinutes: 1, rangeMinutes: 1, notifierIds: [slack.id],});MatchEvent fires for every matching event; Threshold and
AnomalyDetection monitors cover rate-based and baseline-deviation
alerting — see the Axiom overview for those shapes.
Changing a monitor’s type replaces it; everything else updates in
place.
Sibling paths
Section titled “Sibling paths”Two adjacent Cloudflare-native options, in brief:
- Logpush Job — push raw
workers_trace_events(and other Cloudflare datasets) as batched log files to R2, S3, GCS, or an HTTP endpoint. Bulk archival rather than OTLP. - Alerting NotificationPolicy — alerts on Cloudflare platform events (certificate renewals, health checks, …) delivered to email or a NotificationWebhook. Complements Axiom monitors, which alert on your telemetry.
Where next
Section titled “Where next”- Axiom overview — dashboards, annotations, views, and the full resource list.
- Axiom setup — credentials and profiles.
- Telemetry — how the built-in exporters work across every runtime.
- Tutorial Part 6 — the step-by-step version of this setup.
- Observability — OTel across clouds and the receiving end as resources.
- Reference: Dataset · ApiToken · Monitor · Notifier · ObservabilityDestination