1---
2title: Observability
3description: Effect emits OpenTelemetry natively and the exporter is a Layer. Provision the receiving end — datasets, monitors, notifiers, alarms — as resources in the same Stack as the code that emits the signals.
4---
5
6Effect already emits traces, metrics, and logs, and every alchemy
7runtime ships with the OTLP exporter built in — the exporter is a
8Layer, wired to resources through the binding infrastructure. With
9alchemy the receiving end is code too: datasets, ingest tokens,
10monitors, notifiers, and alarms live in the same Stack as the
11Workers and Functions they observe, so a threshold change is a diff
12in the PR — reviewable, revertable, answered by `git blame`.
13
14## OTel everywhere
15
16Every `Effect.withSpan` opens a span, every `Metric` update records a
17data point, every `Effect.logInfo` ships a log line — and each fetch,
18queue message, cron fire, and invocation gets an `http.server`-style
19root span automatically. Where the signals go is decided by a
20telemetry Layer provided on the Function/Worker:
21
22```typescript
23import * as Alchemy from "alchemy";
24
25Effect.provide(
26 Layer.mergeAll(
27 Cloudflare.R2.ReadWriteBucketBinding,
28 Alchemy.Telemetry.layerOtlp({
29 url: "https://collector.example.com",
30 headers: { "x-api-key": apiKey },
31 }),
32 ),
33)
34```
35
36Any OTLP endpoint works — Axiom, an OTel collector, any
37OTLP-compatible vendor. Swap the layer, ship somewhere else; the code
38emitting the signals never changes. Buffered telemetry is flushed
39when each request's scope closes (via `waitUntil` on Cloudflare, so
40export never delays a response). See
41[Telemetry](/infrastructure-as-effects/telemetry) for the full
42mechanics.
43
44## Provision the receiving end
45
46The endpoint the exporter targets is a resource. An
47[Axiom](/axiom) `Dataset` is declared per OTEL signal and exposes its
48OTLP endpoints as outputs:
49
50```typescript
51// src/observability.ts
52import * as Axiom from "alchemy/Axiom";
53
54export const Traces = Axiom.Dataset("Traces", {
55 name: "app-traces",
56 kind: "otel:traces:v1",
57});
58export const Logs = Axiom.Dataset("Logs", {
59 name: "app-logs",
60 kind: "otel:logs:v1",
61});
62```
63
64Each dataset's `otelTracesEndpoint` / `otelLogsEndpoint` /
65`otelMetricsEndpoint` attributes are [Outputs](/infrastructure-as-code/outputs) —
66references you wire into whatever runs the exporter.
67
68## Bind the datasets to the runtime
69
70Mint a least-privilege ingest token, then provide `Axiom.Telemetry`
71on the Worker — a binding layer that wires the datasets and token in
72one line:
73
74```typescript
75// src/observability.ts
76export const Ingest = Axiom.ApiToken("Ingest", {
77 name: "prod-ingest",
78 datasetCapabilities: {
79 "app-traces": { ingest: ["create"] },
80 "app-logs": { ingest: ["create"] },
81 },
82});
83
84// src/worker.ts — in the Worker's constructor Effect:
85import { Ingest, Logs, Traces } from "./observability.ts";
86
87Effect.provide(
88 Layer.mergeAll(
89 Cloudflare.R2.ReadWriteBucketBinding,
90 Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }),
91 ),
92)
93```
94
95Building the layer binds each dataset's OTLP endpoint and the
96token's `Authorization` header onto the Worker — the bearer travels
97as a `secret_text` binding (see [Secrets](/environments/secrets)) and
98never appears in plaintext. At runtime the built-in exporter ships
99each signal to its dataset — no exporter code in the Worker.
100
101## Export from the platform
102
103On Cloudflare you can skip the in-process exporter entirely: an
104`ObservabilityDestination` has Cloudflare push Workers Logs telemetry
105(traces, logs, or metrics) to an OTLP collector — no code changes in
106the Worker:
107
108```typescript
109// in the Stack's generator
110const traces = yield* Traces;
111
112yield* Cloudflare.Workers.ObservabilityDestination("TracesExport", {
113 url: traces.otelTracesEndpoint,
114 headers: { authorization: `Bearer ${process.env.AXIOM_TOKEN}` },
115 logpushDataset: "opentelemetry-traces",
116});
117```
118
119One destination per signal (`opentelemetry-traces`,
120`opentelemetry-logs`, `opentelemetry-metrics`). The endpoint URL and
121headers update in place; changing the dataset triggers a replacement.
122
123## Alerts in code
124
125Monitors and notifiers are resources too. A `Notifier` is an alert
126destination; a `Monitor` is a scheduled APL query that fires it:
127
128```typescript
129const slack = yield* Axiom.Notifier("ops-slack", {
130 name: "ops-channel",
131 properties: {
132 slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! },
133 },
134});
135
136yield* Axiom.Monitor("error-rate", {
137 name: "High error rate",
138 type: "Threshold",
139 aplQuery: `
140 ['app-traces']
141 | where status >= 500
142 | summarize count() by bin_auto(_time)
143 `,
144 operator: "Above",
145 threshold: 100,
146 intervalMinutes: 5,
147 rangeMinutes: 5,
148 notifierIds: [slack.id],
149});
150```
151
152Notifiers cover Slack, email, PagerDuty, Opsgenie, Discord, Teams,
153and custom webhooks; monitors come in `Threshold`, `MatchEvent`, and
154`AnomalyDetection` flavors. Raising the threshold is a one-line diff,
155and the same notifier is reused across every monitor in the stack.
156
157## CloudWatch alarms and dashboards
158
159The same idea on AWS: alarms reference metrics by name and fire SNS
160topics, and dashboards are structured documents — all declared next
161to the resources they observe:
162
163```typescript
164import * as AWS from "alchemy/AWS";
165
166const alerts = yield* AWS.SNS.Topic("Alerts");
167
168const errors = yield* AWS.CloudWatch.Alarm("HighErrors", {
169 MetricName: "Errors",
170 Namespace: "AWS/Lambda",
171 Statistic: "Sum",
172 Period: 60,
173 EvaluationPeriods: 1,
174 Threshold: 1,
175 ComparisonOperator: "GreaterThanOrEqualToThreshold",
176 AlarmActions: [alerts.topicArn],
177});
178
179yield* AWS.CloudWatch.Dashboard("ApiHealth", {
180 DashboardBody: {
181 widgets: [
182 {
183 type: "metric",
184 width: 12,
185 properties: {
186 title: "Lambda errors",
187 view: "timeSeries",
188 stat: "Sum",
189 metrics: [["AWS/Lambda", "Errors", "FunctionName", "api"]],
190 },
191 },
192 {
193 type: "alarm",
194 width: 12,
195 properties: {
196 title: "Alarms",
197 alarms: [errors.alarmArn],
198 },
199 },
200 ],
201 },
202});
203```
204
205`AWS.CloudWatch.CompositeAlarm` ANDs/ORs alarm states into a
206higher-level alert (`AlarmRule: 'ALARM("HighErrors") OR
207ALARM("HighLatency")'`), and because stacks are per-stage, `prod`,
208`staging`, and `pr-42` each get their own dashboards and alarms.
209
210## Where next
211
212- [Telemetry](/infrastructure-as-effects/telemetry) — how the built-in exporters work across every runtime, and the override API
213- [Axiom](/axiom) — datasets, tokens, monitors, notifiers, and dashboards as resources
214- [Axiom observability guide](/cloudflare/observability/axiom-observability) — end-to-end setup for a Cloudflare Worker
215- [Tutorial Part 6](/cloudflare/tutorial/part-6) — the step-by-step version
216- [Secrets](/environments/secrets) — how `Redacted` values like ingest tokens are bound