Skip to content

Observability

Effect already emits traces, metrics, and logs. The exporter is a Layer — point it at any OTLP endpoint. With alchemy the receiving end is code too: datasets, ingest tokens, monitors, notifiers, and alarms live in the same Stack as the Workers and Functions they observe, so a threshold change is a diff in the PR — reviewable, revertable, answered by git blame.

Every Effect.withSpan opens a span, every Metric update records a data point, every Effect.logInfo ships a log line. Where the signals go is decided by a Layer — Effect ships OTLP exporters out of the box:

import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
const Tracing = OtlpTracer.layer({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT!,
headers: { Authorization: `Bearer ${process.env.AXIOM_TOKEN}` },
resource: { serviceName: "api" },
}).pipe(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
);

Provide the Layer (Effect.provide(Tracing)) and every span flows to whatever OTLP endpoint the environment points at — Axiom, an OTel collector, any OTLP-compatible vendor. Swap the URL, ship somewhere else; the code emitting the signals never changes. OtlpLogger and OtlpMetrics follow the same shape for logs and metrics.

The endpoint the exporter targets is a resource. An Axiom Dataset is created per OTEL signal and exposes its OTLP endpoints as outputs:

import * as Axiom from "alchemy/Axiom";
const traces = yield* Axiom.Dataset("traces", {
name: "app-traces",
kind: "otel:traces:v1",
});
const logs = yield* Axiom.Dataset("logs", {
name: "app-logs",
kind: "otel:logs:v1",
});

Each dataset’s otelTracesEndpoint / otelLogsEndpoint / otelMetricsEndpoint attributes are Outputs — references you wire into whatever runs the exporter.

Mint a least-privilege ingest token and pass both the endpoint and the token to the Worker through env:

const ingest = yield* Axiom.ApiToken("ingest", {
name: "prod-ingest",
datasetCapabilities: {
"app-traces": { ingest: ["create"] },
},
});
const api = yield* Cloudflare.Worker("Api", {
main: "./src/worker.ts",
env: {
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: traces.otelTracesEndpoint,
AXIOM_TOKEN: ingest.token,
},
});

The endpoint is a plain string so it binds as plain_text; the token is a Redacted so it binds as secret_text — see Secrets. At runtime the exporter Layer from the first snippet reads these and starts shipping.

On Cloudflare you can skip the in-process exporter entirely: an ObservabilityDestination has Cloudflare push Workers Logs telemetry (traces, logs, or metrics) to an OTLP collector — no code changes in the Worker:

yield* Cloudflare.Workers.ObservabilityDestination("Traces", {
url: traces.otelTracesEndpoint,
headers: { authorization: `Bearer ${process.env.AXIOM_TOKEN}` },
logpushDataset: "opentelemetry-traces",
});

One destination per signal (opentelemetry-traces, opentelemetry-logs, opentelemetry-metrics). The endpoint URL and headers update in place; changing the dataset triggers a replacement.

Monitors and notifiers are resources too. A Notifier is an alert destination; 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("error-rate", {
name: "High error rate",
type: "Threshold",
aplQuery: `
['app-traces']
| where status >= 500
| summarize count() by bin_auto(_time)
`,
operator: "Above",
threshold: 100,
intervalMinutes: 5,
rangeMinutes: 5,
notifierIds: [slack.id],
});

Notifiers cover Slack, email, PagerDuty, Opsgenie, Discord, Teams, and custom webhooks; monitors come in Threshold, MatchEvent, and AnomalyDetection flavors. Raising the threshold is a one-line diff, and the same notifier is reused across every monitor in the stack.

The same idea on AWS: alarms reference metrics by name and fire SNS topics, and dashboards are structured documents — all declared next to the resources they observe:

import * as AWS from "alchemy/AWS";
const alerts = yield* AWS.SNS.Topic("Alerts");
const errors = yield* AWS.CloudWatch.Alarm("HighErrors", {
MetricName: "Errors",
Namespace: "AWS/Lambda",
Statistic: "Sum",
Period: 60,
EvaluationPeriods: 1,
Threshold: 1,
ComparisonOperator: "GreaterThanOrEqualToThreshold",
AlarmActions: [alerts.topicArn],
});
yield* AWS.CloudWatch.Dashboard("ApiHealth", {
DashboardBody: {
widgets: [
{
type: "metric",
width: 12,
properties: {
title: "Lambda errors",
view: "timeSeries",
stat: "Sum",
metrics: [["AWS/Lambda", "Errors", "FunctionName", "api"]],
},
},
{
type: "alarm",
width: 12,
properties: {
title: "Alarms",
alarms: [errors.alarmArn],
},
},
],
},
});

AWS.CloudWatch.CompositeAlarm ANDs/ORs alarm states into a higher-level alert (AlarmRule: 'ALARM("HighErrors") OR ALARM("HighLatency")'), and because stacks are per-stage, prod, staging, and pr-42 each get their own dashboards and alarms.

  • Axiom — datasets, tokens, monitors, notifiers, and dashboards as resources
  • Axiom observability guide — end-to-end setup for a Cloudflare Worker
  • Secrets — how Redacted values like ingest tokens are bound