Skip to content

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:

  1. From the Worker — the Axiom.Telemetry binding layer; the built-in telemetry exports your Effect spans, logs, and metrics directly.
  2. From Cloudflare — an ObservabilityDestination that pushes Workers Logs telemetry to Axiom.

Axiom resources deploy alongside your Cloudflare ones — merge the provider layers in your Stack:

alchemy.run.ts
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.

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:

src/observability.ts
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.

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.

An ApiToken scoped with datasetCapabilities can ingest into exactly the datasets you list and nothing else:

src/observability.ts
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.

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:

src/worker.ts
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.

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 generator
import * 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 } },
});

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.

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.