Effect SDK on Cloudflare Workers
Set up the Effect SDK on Cloudflare Workers with explicit flush() in ctx.waitUntil and in-isolate buffering.
The /cloudflare entry point of @maple-dev/effect-sdk is built for Cloudflare Workers. A Worker has no long-running process: the isolate handles a request, returns a response, and the runtime may suspend it at any moment. A background export fiber does not run reliably between invocations, so this entry point buffers telemetry inside the isolate and sends it when you call flush(env), which you schedule with ctx.waitUntil.
Already installed the SDK? If not, see the install instructions.
Why Workers are different
- No background fiber. Spans, logs and metrics accumulate in memory inside the isolate and are only sent when you call
flush(). - Configuration on first flush. You can call
make()at module scope withoutenv. The SDK reads the endpoint, ingest key and resource attributes fromenvon the firstflush(env)call. - Manual lifecycle.
ctx.waitUntil(telemetry.flush(env))keeps the isolate alive long enough to send the batch after the response is returned.
Quick start
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
import { HttpRouter } from "effect/http"
import { Layer } from "effect"
import { Routes } from "./routes"
const telemetry = MapleCloudflareSDK.make({ serviceName: "my-worker" })
const { handler } = HttpRouter.toWebHandler(Routes.pipe(Layer.provideMerge(telemetry.layer)))
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const res = await handler(req)
ctx.waitUntil(telemetry.flush(env))
return res
},
}
telemetry.layer must be merged into the same Effect runtime that runs your routes, because the tracer has to be in scope when spans are created. A separate per-request runtime does not work.
Runtimes that own a per-request scope can use telemetry.requestLayer instead of calling flush themselves. It is the same layer plus a flush when the scope closes, and it reads env from the WorkerEnvironment service exported by this entry point.
No-op mode
When no ingest key is set (neither ingestKey nor MAPLE_INGEST_KEY), the SDK runs in no-op mode. Each flush() still empties the buffers so they do not grow for the life of the isolate, but nothing is sent. The first flush logs one console.info line starting with [MapleCloudflareSDK] no ingest key configured. The same code can therefore run in preview environments that have no key.
Failure handling
A flush fails when the request throws (for example a network error) or the ingest answers with any non-2xx status, including 401 and 429. The failed batch goes back into the buffer ahead of newer telemetry, and that signal enters a 60-second cooldown. During the cooldown, flushes for that signal skip the request and log a warning. Traces, logs and metrics each have their own cooldown. Errors are logged to console.error and never reach your handler.
Cloudflare-specific config
make() accepts the common options marked for Cloudflare (serviceName, region, endpoint, ingestKey, serviceVersion, serviceNamespace, environment, repositoryUrl, attributes), plus these:
| Option | Type | Default | Description |
|---|---|---|---|
excludeLogSpans | boolean | false | Skip Effect log spans in OTLP log attributes |
dropSpanNames | ReadonlyArray<string> | none | Drop spans whose name starts with any prefix in this list |
anticipatedErrorIdentifiers | ReadonlyArray<string> | none | _tag / Error.name values of expected 4xx failures. Spans export as Ok, with no exception event |
tracesPath | string | /v1/traces | OTLP traces path appended to endpoint |
logsPath | string | /v1/logs | OTLP logs path appended to endpoint |
metricsPath | string | /v1/metrics | OTLP metrics path appended to endpoint |
anticipatedErrorIdentifiers keeps expected rejections (a 404, a 401) visible as traces without counting them as errors. A span still exports as Error if its cause contains any defect.
An error that crossed an HTTP boundary is a decoded body rather than the class that raised it, so a failure shaped { error: { _tag } } (the envelope convention many APIs use) is matched on the body’s _tag. Client-side spans classify the same as the server-side ones they mirror, with no separate identifiers to configure.
dropSpanNames suppresses protocol-level chatter. For example, ["McpServer/Notifications."] drops MCP notification spans without dropping handler spans.
Endpoint resolution
The endpoint is the first of:
config.endpointenv.MAPLE_ENDPOINTenv.OTEL_EXPORTER_OTLP_ENDPOINT- The ingest for
config.region, then forenv.MAPLE_REGION:https://ingest.maple.devforus,https://ingest.eu.maple.devforeu https://ingest.maple.dev
Any explicit endpoint beats any region. On hosted Maple, a MAPLE_INGEST_KEY secret is usually all you need, plus MAPLE_REGION=eu for an EU organization. Other variables read from env: OTEL_SERVICE_NAME, MAPLE_ENVIRONMENT, MAPLE_REPOSITORY_URL, OTEL_RESOURCE_ATTRIBUTES, and the commit SHA variables listed on the server page.
Verify
- Set the key as a secret with
wrangler secret put MAPLE_INGEST_KEY(an ingest key from Settings → Ingestion), then deploy withwrangler deploy. - Send a few requests to the Worker.
- In Maple, open Explore → Traces. Each request should show up as one trace, rooted at the server span for the request.
Troubleshooting
Run wrangler tail and look for lines starting with [MapleCloudflareSDK]:
no ingest key configured. The Worker has noMAPLE_INGEST_KEYinenv. Set it withwrangler secret put MAPLE_INGEST_KEY.flush failed; cooldown 60swithOTLP 401. The key is wrong, or it belongs to the other region. An EU key needsMAPLE_REGION=eu.flush skipped (cooldown ...). An earlier flush failed. Look further back in the log for the failure.- No lines and no data.
ctx.waitUntil(telemetry.flush(env))is not being called, so the isolate stops before the request is sent. Ortelemetry.layeris provided to a different runtime than your routes.