Skip to content

feat(effect-sdk): opt-in native Cloudflare tracer for the Workers preset - #731

Open
Makisuo wants to merge 1 commit into
mainfrom
claude/admiring-elion-32be8d
Open

feat(effect-sdk): opt-in native Cloudflare tracer for the Workers preset#731
Makisuo wants to merge 1 commit into
mainfrom
claude/admiring-elion-32be8d

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an experimental tracer: "native" mode to @maple-dev/effect-sdk/cloudflare. Every sampled Effect span is mirrored onto tracing.startActiveSpan from cloudflare:workers, so it joins the same trace as Cloudflare's auto-instrumented fetch/KV/R2/D1 spans and is exported by the customer's ObservabilityDestination. No in-isolate buffer, no ctx.waitUntil(flush), no ingest key in the Worker. The default OTLP mode and the api/alerting workers are unchanged.

Why

The OTLP buffer flush is unreliable from Durable Object and Workflow isolates and requires an ingest key in the Worker. Cloudflare's own tracing (compatibility date >= 2026-07-28) can carry the spans out instead.

How

  • cloudflare/native-tracer.ts: an Effect Tracer.make over startActiveSpan. Each mirrored span captures AsyncLocalStorage.snapshot() inside its callback, children open inside the parent's snapshot, and the context hook runs every fiber step inside the current span's snapshot. Cloudflare's isTraced cascades into Effect's sampled, so an untraced invocation opens no Cloudflare spans at all.
  • Failures become attributes (exception.type, exception.message, exception.stacktrace, error.type) since Cloudflare spans have no events. A parallel change teaches the error MV to read those attributes.
  • Scalar attributes are forwarded on set; objects, arrays, bigints, events and links stay Effect-local.
  • shared/span-exit.ts: the success / interrupted / ignored / anticipated / failed classification is shared between the OTLP buffer tracer and the native tracer so they cannot disagree. The OTLP tracer's behaviour is unchanged.
  • cloudflare:workers and node:async_hooks are imported dynamically by a variable specifier and narrowed by type guards (the installed workers-types only know enterSpan). A missing API fails with NativeTracingUnavailable, which the layer turns into one Effect.logInfo notice plus Effect-local spans.
  • Logs are left to Workers Logs in native mode: OTLP log records would carry Effect trace ids that never match the Cloudflare trace ids on the exported spans, and would need exactly the flush and key this mode removes. Metrics are likewise not exported. Documented in the README.

Reviewer notes

  • Known limitation, documented in the README: the layer is module-scoped, so it cannot capture a per-invocation snapshot. Root spans use the ambient async context at open time, and within one continuation the ambient context can still be the span that just ended. This should be validated on a real Worker before the mode leaves "experimental", along with the attribute size limit for the mirrored stack trace.
  • The Config.tracer option and the README section are the only public surface changes.

Testing

  • native-tracer.test.ts: a fake tracing backed by a real AsyncLocalStorage asserts parentage across a scheduler hop into a foreign async context, interleaved fibers, attribute forwarding, all five exit classes, the isTraced cascade, dropped names, host resolution failures, and the fallback path.
  • index.test.ts: native mode off Workers builds the layer, makes no fetch calls, resolves flush, and logs one notice.
  • effect-sdk vitest suite: 17 files, 143 tests pass. tsc --noEmit, oxlint and oxfmt clean on the package. tsdown build confirmed the dynamic imports survive unbundled.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Add `tracer: "native"` to `MapleCloudflareSDK.make`. Every sampled Effect
span is mirrored onto `tracing.startActiveSpan` from `cloudflare:workers`,
so it lands in the same trace as Cloudflare's own fetch/KV/R2/D1 spans and
is exported by the Worker's ObservabilityDestination: no in-isolate buffer,
no `ctx.waitUntil` flush, no ingest key in the Worker. That also makes it
usable from Durable Object and Workflow isolates, where the flush is
unreliable.

Each mirrored span captures `AsyncLocalStorage.snapshot()` inside its
callback, children open inside the parent's snapshot, and the tracer's
`context` hook runs every fiber step inside the current span's snapshot,
so spans opened after a yield still nest correctly and runtime spans
attach under the right Effect span. Cloudflare's `isTraced` cascades into
Effect's `sampled`.

Cloudflare spans carry only scalar attributes, so a failed exit is
recorded as `exception.type` / `exception.message` /
`exception.stacktrace` / `error.type` instead of an `exception` event;
non-scalar attributes, events and links stay Effect-local. The exit
classification shared with the OTLP buffer tracer moves to
`shared/span-exit.ts` so both paths agree on what is an error.

Logs stay with Workers Logs in this mode (no OTLP logger): their Effect
trace ids would never match the Cloudflare trace ids on the exported spans.

`cloudflare:workers` and `node:async_hooks` are imported dynamically by a
variable specifier and narrowed by type guards; when either API is absent
the layer logs one notice and keeps spans Effect-local. The default mode
and the api/alerting workers are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant